From 526a1c9c6185d51996aeb1c52e1fef055806cd56 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 2 Sep 2025 23:11:25 +1000 Subject: [PATCH 01/15] Roadmaps initial version --- apps/page/components/page-header.tsx | 131 ++- apps/page/components/subscribe-prompt.tsx | 197 ++-- apps/page/pages/_sites/[site]/index.tsx | 21 +- .../_sites/[site]/roadmap/[roadmap_slug].tsx | 533 ++++++++++ apps/page/pages/api/roadmap/vote.ts | 83 ++ apps/page/pages/api/roadmap/votes.ts | 52 + apps/page/pages/api/roadmap/votes/bulk.ts | 104 ++ apps/page/tailwind.config.js | 16 + apps/web/components/layout/page.component.tsx | 8 +- apps/web/pages/pages/[page_id]/index.tsx | 17 + .../pages/[page_id]/roadmap/[board_id].tsx | 923 ++++++++++++++++++ .../[page_id]/roadmap/[board_id]/settings.tsx | 890 +++++++++++++++++ .../pages/pages/[page_id]/roadmap/index.tsx | 171 ++++ .../web/pages/pages/[page_id]/roadmap/new.tsx | 279 ++++++ apps/web/styles/global.css | 1 + apps/web/tailwind.config.js | 16 + packages/supabase/migrations/18_roadmap.sql | 166 ++++ packages/supabase/types/index.ts | 235 ++++- packages/utils/index.ts | 1 + packages/utils/roadmap/index.ts | 78 ++ 20 files changed, 3793 insertions(+), 129 deletions(-) create mode 100644 apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx create mode 100644 apps/page/pages/api/roadmap/vote.ts create mode 100644 apps/page/pages/api/roadmap/votes.ts create mode 100644 apps/page/pages/api/roadmap/votes/bulk.ts create mode 100644 apps/web/pages/pages/[page_id]/roadmap/[board_id].tsx create mode 100644 apps/web/pages/pages/[page_id]/roadmap/[board_id]/settings.tsx create mode 100644 apps/web/pages/pages/[page_id]/roadmap/index.tsx create mode 100644 apps/web/pages/pages/[page_id]/roadmap/new.tsx create mode 100644 packages/supabase/migrations/18_roadmap.sql create mode 100644 packages/utils/roadmap/index.ts diff --git a/apps/page/components/page-header.tsx b/apps/page/components/page-header.tsx index 448fbfa..8bbe75c 100644 --- a/apps/page/components/page-header.tsx +++ b/apps/page/components/page-header.tsx @@ -1,15 +1,32 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; +import { Menu } from "@headlessui/react"; +import { ChevronDownIcon } from "@heroicons/react/outline"; import classNames from "classnames"; import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; import OptionalLink from "./optional-link"; +type RoadmapBoard = { + id: string; + title: string; + slug: string; + description?: string; +}; + export default function PageHeader({ page, settings, + roadmaps = [], }: { page: IPage; settings: IPageSettings; + roadmaps?: RoadmapBoard[]; }) { + const router = useRouter(); + const currentPath = router.asPath; + const isRoadmapPage = currentPath.includes("/roadmap/"); + return ( <> {settings?.custom_css ? ( @@ -33,40 +50,118 @@ export default function PageHeader({
-
-
- - {settings?.page_logo ? ( - <> +
+
+
+ {settings?.page_logo && ( + {page?.title} -

+ + )} +
+ +

{page?.title}

- - ) : ( -

- {page?.title} -

- )} -
+ +
+

{page?.description && ( -

+

{page?.description}

)} + + {/* Navigation bar - only show if there are roadmaps */} + {roadmaps.length > 0 && ( +
+ +
+ )}
diff --git a/apps/page/components/subscribe-prompt.tsx b/apps/page/components/subscribe-prompt.tsx index 5f65375..9bccb86 100644 --- a/apps/page/components/subscribe-prompt.tsx +++ b/apps/page/components/subscribe-prompt.tsx @@ -1,6 +1,11 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; import { Spinner } from "@changes-page/ui"; -import { CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/solid"; +import { + BellIcon, + CheckCircleIcon, + InformationCircleIcon, + RssIcon, +} from "@heroicons/react/solid"; import classNames from "classnames"; import { useFormik } from "formik"; import { useMemo, useState } from "react"; @@ -17,6 +22,7 @@ export default function SubscribePrompt({ }) { const [loading, setLoading] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const [showForm, setShowForm] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const pageUrl = useMemo(() => getPageUrl(page, settings), [page, settings]); @@ -48,7 +54,6 @@ export default function SubscribePrompt({ } setLoading(false); - // alert("Something went wrong. Please try again later."); console.error("/api/notification/email: error", e); } }, @@ -58,72 +63,70 @@ export default function SubscribePrompt({ return null; } - if (!settings?.email_notifications && settings) { + if (!settings?.email_notifications && settings?.rss_notifications) { return ( -
-
- -

- Get posts via - - {" "} - - RSS - {" "} - /{" "} - - Atom - {" "} - feed - -

+ ); } return ( <> -
-
- -

- Subscribe to get future posts via email -

- {settings.rss_notifications ? ( -

- - {" "} - (or grab the{" "} - - RSS - {" "} - /{" "} - + {!showForm && !showSuccess ? ( +

) : null} {errorMessage && ( @@ -154,7 +157,46 @@ export default function SubscribePrompt({
)} - {showSuccess ? ( + {showForm && !showSuccess && ( +
+

+ Subscribe to get future posts via email +

+
+ + +
+ +
+
+
+ )} + + {showSuccess && (
- ) : ( -
- - - -
- -
-
)}
diff --git a/apps/page/pages/_sites/[site]/index.tsx b/apps/page/pages/_sites/[site]/index.tsx index 2ed083f..b6f8c8e 100644 --- a/apps/page/pages/_sites/[site]/index.tsx +++ b/apps/page/pages/_sites/[site]/index.tsx @@ -14,17 +14,27 @@ import { fetchRenderData, isSubscriptionActive, } from "../../../lib/data"; +import { supabaseAdmin } from "@changes-page/supabase/admin"; + +type RoadmapBoard = { + id: string; + title: string; + slug: string; + description?: string; +}; export default function Index({ posts: initialPosts, page, postsCount, settings, + roadmaps, }: { page: IPage; settings: IPageSettings; posts: IPost[]; postsCount: number; + roadmaps: RoadmapBoard[]; }) { const { setTheme } = useTheme(); @@ -71,7 +81,7 @@ export default function Index({
- + {(settings?.email_notifications || settings?.rss_notifications) && ( @@ -179,12 +189,21 @@ export async function getServerSideProps({ limit: 10, }); + // Fetch public roadmaps + const { data: roadmaps } = await supabaseAdmin + .from("roadmap_boards") + .select("id, title, slug, description") + .eq("page_id", page.id) + .eq("is_public", true) + .order("created_at", { ascending: true }); + return { props: { page, posts, postsCount, settings, + roadmaps: roadmaps || [], }, }; } diff --git a/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx new file mode 100644 index 0000000..bbef211 --- /dev/null +++ b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx @@ -0,0 +1,533 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import { Database } from "@changes-page/supabase/types"; +import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; +import { getCategoryColorClasses } from "@changes-page/utils"; +import { Dialog, Transition } from "@headlessui/react"; +import { XIcon } from "@heroicons/react/outline"; +import { useTheme } from "next-themes"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import Footer from "../../../../components/footer"; +import PageHeader from "../../../../components/page-header"; +import SeoTags from "../../../../components/seo-tags"; +import { + BLACKLISTED_SLUGS, + fetchRenderData, + isSubscriptionActive, +} from "../../../../lib/data"; + +type RoadmapBoard = Database["public"]["Tables"]["roadmap_boards"]["Row"]; +type RoadmapColumn = Database["public"]["Tables"]["roadmap_columns"]["Row"]; +type RoadmapCategory = + Database["public"]["Tables"]["roadmap_categories"]["Row"]; +type RoadmapItem = Database["public"]["Tables"]["roadmap_items"]["Row"] & { + roadmap_categories?: RoadmapCategory | null; + vote_count?: number; +}; + +interface RoadmapPageProps { + page: IPage; + settings: IPageSettings; + board: RoadmapBoard; + columns: RoadmapColumn[]; + items: RoadmapItem[]; + roadmaps: Array<{ + id: string; + title: string; + slug: string; + description?: string; + }>; +} + +export default function RoadmapPage({ + page, + settings, + board, + columns, + items, + roadmaps, +}: RoadmapPageProps) { + const { setTheme } = useTheme(); + const [selectedItem, setSelectedItem] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [votes, setVotes] = useState< + Record + >({}); + const [votingItems, setVotingItems] = useState>(new Set()); + + useEffect(() => { + if (settings?.color_scheme != "auto") { + setTheme(settings?.color_scheme); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.color_scheme]); + + // Organize items by column + const itemsByColumn = useMemo(() => { + const organized: Record = {}; + columns.forEach((column) => { + organized[column.id] = items + .filter((item) => item.column_id === column.id) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + }); + return organized; + }, [columns, items]); + + const openItemModal = (item: RoadmapItem) => { + setSelectedItem(item); + setIsModalOpen(true); + }; + + const closeItemModal = () => { + setIsModalOpen(false); + setSelectedItem(null); + }; + + const handleVote = async (itemId: string) => { + // Prevent voting if already in progress or if user has already voted and we're not toggling + if (votingItems.has(itemId)) return; + + setVotingItems((prev) => new Set(prev).add(itemId)); + + // Optimistic update + const currentVoteState = votes[itemId]; + const newVotedState = !currentVoteState?.voted; + const newCount = currentVoteState?.count + ? newVotedState + ? currentVoteState.count + 1 + : currentVoteState.count - 1 + : newVotedState + ? 1 + : 0; + + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: newCount, + voted: newVotedState, + }, + })); + + try { + const response = await fetch("/api/roadmap/vote", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ item_id: itemId }), + }); + + const data = await response.json(); + + if (data.ok) { + // Update with server response to ensure consistency + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: data.vote_count || 0, + voted: newVotedState, + }, + })); + } else { + console.error("Voting error:", data.error || "Failed to vote"); + // Revert optimistic update on error + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: currentVoteState?.count || 0, + voted: currentVoteState?.voted || false, + }, + })); + } + } catch (error) { + console.error("Error voting:", error); + // Revert optimistic update on error + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: currentVoteState?.count || 0, + voted: currentVoteState?.voted || false, + }, + })); + } finally { + setVotingItems((prev) => { + const newSet = new Set(prev); + newSet.delete(itemId); + return newSet; + }); + } + }; + + // Initialize vote data for items + useEffect(() => { + const fetchVotes = async () => { + if (items.length === 0) return; + + try { + const response = await fetch("/api/roadmap/votes/bulk", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + item_ids: items.map(item => item.id), + }), + }); + + const data = await response.json(); + + if (data.ok) { + // Transform API response to match expected frontend structure + const transformedVotes: Record = {}; + Object.entries(data.votes).forEach(([itemId, voteData]: [string, any]) => { + transformedVotes[itemId] = { + count: voteData.vote_count, + voted: voteData.user_voted, + }; + }); + setVotes(transformedVotes); + } + } catch (error) { + console.error("Error fetching votes:", error); + } + }; + + fetchVotes(); + }, [items]); + + return ( + <> + + +
+ + + {/* Kanban Board Container */} +
+
+ {/* Roadmap Header */} +
+
+

+ {board.title} +

+ {board.description && ( +

+ {board.description} +

+ )} +
+
+ +
+
+
+ {columns.map((column) => ( +
+ {/* Column Header */} +
+
+

+ {column.name} +

+ + {itemsByColumn[column.id]?.length || 0} + +
+
+ + {/* Column Items */} +
+ {itemsByColumn[column.id]?.map((item) => ( +
openItemModal(item)} + className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer" + > +

+ {item.title} +

+ + {/* Bottom row with category and votes */} +
+
+ {item.roadmap_categories && ( + + {item.roadmap_categories.name} + + )} +
+ +
+
+ ))} + + {/* Empty state */} + {(!itemsByColumn[column.id] || + itemsByColumn[column.id].length === 0) && ( +
+ No items in this stage yet +
+ )} +
+
+ ))} +
+
+
+ + {/* Empty state for no columns */} + {columns.length === 0 && ( +
+
+

+ This roadmap is being set up +

+

Check back soon for updates!

+
+
+ )} +
+
+ +
+
+ + {/* Item Details Modal */} + + + +
+ + +
+
+ + +
+ + {selectedItem?.title} + + +
+ + {selectedItem?.description && ( +
+

+ {selectedItem.description} +

+
+ )} + +
+
+ {selectedItem?.roadmap_categories && ( + + {selectedItem.roadmap_categories.name} + + )} +
+ +
+
+
+
+
+
+
+ + ); +} + +export async function getServerSideProps({ + params: { site, roadmap_slug }, +}: { + params: { site: string; roadmap_slug: string }; +}) { + console.log("handle roadmap ->", site, roadmap_slug); + + if (!site || !roadmap_slug) { + return { + notFound: true, + }; + } + + if (BLACKLISTED_SLUGS.includes(site)) { + return { + notFound: true, + }; + } + + const { page, settings } = await fetchRenderData(site); + + if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { + return { + notFound: true, + }; + } + + // Fetch the roadmap board + const { data: board, error: boardError } = await supabaseAdmin + .from("roadmap_boards") + .select("*") + .eq("page_id", page.id) + .eq("slug", roadmap_slug) + .eq("is_public", true) + .single(); + + if (boardError || !board) { + console.error("Failed to fetch roadmap board", boardError); + return { + notFound: true, + }; + } + + // Fetch columns for this board + const { data: columns, error: columnsError } = await supabaseAdmin + .from("roadmap_columns") + .select("*") + .eq("board_id", board.id) + .order("position", { ascending: true }); + + if (columnsError) { + console.error("Failed to fetch columns", columnsError); + } + + // Fetch items for this board with category information + const { data: items, error: itemsError } = await supabaseAdmin + .from("roadmap_items") + .select( + ` + *, + roadmap_categories ( + id, + name, + color + ) + ` + ) + .eq("board_id", board.id) + .order("position", { ascending: true }); + + if (itemsError) { + console.error("Failed to fetch items", itemsError); + } + + // Fetch all public roadmaps for navigation + const { data: roadmaps } = await supabaseAdmin + .from("roadmap_boards") + .select("id, title, slug, description") + .eq("page_id", page.id) + .eq("is_public", true) + .order("created_at", { ascending: true }); + + return { + props: { + page, + settings, + board, + columns: columns || [], + items: items || [], + roadmaps: roadmaps || [], + }, + }; +} diff --git a/apps/page/pages/api/roadmap/vote.ts b/apps/page/pages/api/roadmap/vote.ts new file mode 100644 index 0000000..784cd04 --- /dev/null +++ b/apps/page/pages/api/roadmap/vote.ts @@ -0,0 +1,83 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; +import { supabaseAdmin } from "@changes-page/supabase/admin"; + +export default async function voteOnRoadmapItem( + req: NextApiRequest, + res: NextApiResponse<{ ok: boolean; vote_count?: number; error?: string }> +) { + if (req.method !== 'POST') { + return res.status(405).json({ ok: false, error: 'Method not allowed' }); + } + + const { item_id } = req.body; + + if (!item_id) { + return res.status(400).json({ ok: false, error: 'Missing item_id' }); + } + + let { cp_pa_vid: visitor_id } = req.cookies; + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Check if user has already voted + const { data: existingVote } = await supabaseAdmin + .from("roadmap_votes") + .select("*") + .eq("item_id", item_id) + .eq("visitor_id", visitor_id) + .maybeSingle(); + + if (existingVote) { + // Remove vote (toggle off) + const { error: deleteError } = await supabaseAdmin + .from("roadmap_votes") + .delete() + .eq("id", existingVote.id); + + if (deleteError) { + console.error("voteOnRoadmapItem [Delete Error]", deleteError); + return res.status(500).json({ ok: false, error: 'Failed to remove vote' }); + } + } else { + // Add vote + const { error: insertError } = await supabaseAdmin + .from("roadmap_votes") + .insert({ + id: v4(), + item_id: String(item_id), + visitor_id: visitor_id, + }); + + if (insertError) { + console.error("voteOnRoadmapItem [Insert Error]", insertError); + return res.status(500).json({ ok: false, error: 'Failed to add vote' }); + } + } + + // Get updated vote count + const { data: voteCount, error: countError } = await supabaseAdmin + .from("roadmap_votes") + .select("id", { count: 'exact' }) + .eq("item_id", item_id); + + if (countError) { + console.error("voteOnRoadmapItem [Count Error]", countError); + } + + res.status(200).json({ + ok: true, + vote_count: voteCount?.length || 0 + }); + } catch (e: Error | any) { + console.log("voteOnRoadmapItem [Error]", e); + res.status(500).json({ ok: false, error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/apps/page/pages/api/roadmap/votes.ts b/apps/page/pages/api/roadmap/votes.ts new file mode 100644 index 0000000..ea62588 --- /dev/null +++ b/apps/page/pages/api/roadmap/votes.ts @@ -0,0 +1,52 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; +import { supabaseAdmin } from "@changes-page/supabase/admin"; + +export default async function getRoadmapItemVotes( + req: NextApiRequest, + res: NextApiResponse<{ ok: boolean; vote_count: number; user_voted: boolean }> +) { + let { item_id } = req.query; + let { cp_pa_vid: visitor_id } = req.cookies; + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Get vote count + const { data: voteCount, error: countError } = await supabaseAdmin + .from("roadmap_votes") + .select("id", { count: 'exact' }) + .eq("item_id", String(item_id)); + + if (countError) { + console.error("getRoadmapItemVotes [Count Error]", countError); + } + + // Check if current user voted + const { data: userVote, error: userError } = await supabaseAdmin + .from("roadmap_votes") + .select("id") + .eq("item_id", String(item_id)) + .eq("visitor_id", visitor_id) + .maybeSingle(); + + if (userError) { + console.error("getRoadmapItemVotes [User Error]", userError); + } + + res.status(200).json({ + ok: true, + vote_count: voteCount?.length || 0, + user_voted: !!userVote, + }); + } catch (e: Error | any) { + console.log("getRoadmapItemVotes [Error]", e); + res.status(200).json({ ok: true, vote_count: 0, user_voted: false }); + } +} \ No newline at end of file diff --git a/apps/page/pages/api/roadmap/votes/bulk.ts b/apps/page/pages/api/roadmap/votes/bulk.ts new file mode 100644 index 0000000..2c7a4d9 --- /dev/null +++ b/apps/page/pages/api/roadmap/votes/bulk.ts @@ -0,0 +1,104 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; + +type BulkVotesResponse = { + ok: boolean; + votes: Record; +}; + +// UUID validation regex +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export default async function getBulkRoadmapItemVotes( + req: NextApiRequest, + res: NextApiResponse +) { + // Validate HTTP method + if (req.method !== "POST") { + return res.status(405).json({ ok: false, votes: {} }); + } + + const { item_ids } = req.body; + let { cp_pa_vid: visitor_id } = req.cookies; + + // Input validation + if (!item_ids || !Array.isArray(item_ids)) { + return res.status(400).json({ ok: false, votes: {} }); + } + + // Prevent abuse with max array length + if (item_ids.length > 100) { + return res.status(400).json({ ok: false, votes: {} }); + } + + // Validate all item_ids are valid UUIDs + if (!item_ids.every((id) => typeof id === "string" && UUID_REGEX.test(id))) { + return res.status(400).json({ ok: false, votes: {} }); + } + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + const [voteCountResult, userVoteResult] = await Promise.all([ + supabaseAdmin + .from("roadmap_votes") + .select("item_id") + .in("item_id", item_ids), + supabaseAdmin + .from("roadmap_votes") + .select("item_id") + .in("item_id", item_ids) + .eq("visitor_id", visitor_id), + ]); + + if (voteCountResult.error) { + console.error( + "getBulkRoadmapItemVotes [Count Error]", + voteCountResult.error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + + if (userVoteResult.error) { + console.error( + "getBulkRoadmapItemVotes [User Error]", + userVoteResult.error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + + const voteCountsMap = (voteCountResult.data || []).reduce((acc, vote) => { + acc[vote.item_id] = (acc[vote.item_id] || 0) + 1; + return acc; + }, {} as Record); + + const userVotedSet = new Set( + (userVoteResult.data || []).map((vote) => vote.item_id) + ); + + const votes: Record = + {}; + item_ids.forEach((itemId: string) => { + votes[itemId] = { + vote_count: voteCountsMap[itemId] || 0, + user_voted: userVotedSet.has(itemId), + }; + }); + + res.status(200).json({ + ok: true, + votes, + }); + } catch (e: Error | any) { + console.log("getBulkRoadmapItemVotes [Error]", e); + res.status(500).json({ ok: false, votes: {} }); + } +} diff --git a/apps/page/tailwind.config.js b/apps/page/tailwind.config.js index d4536ba..96a2c1a 100644 --- a/apps/page/tailwind.config.js +++ b/apps/page/tailwind.config.js @@ -7,6 +7,22 @@ module.exports = { "./components/**/*.{js,ts,jsx,tsx}", "./node_modules/@changes-page/ui/components/**/*.{js,ts,jsx,tsx}", ], + safelist: [ + // Roadmap category colors + 'bg-blue-100', 'text-blue-800', 'border-blue-200', 'dark:bg-blue-900', 'dark:text-blue-200', 'dark:border-blue-800', + 'bg-indigo-100', 'text-indigo-800', 'border-indigo-200', 'dark:bg-indigo-900', 'dark:text-indigo-200', 'dark:border-indigo-800', + 'bg-purple-100', 'text-purple-800', 'border-purple-200', 'dark:bg-purple-900', 'dark:text-purple-200', 'dark:border-purple-800', + 'bg-pink-100', 'text-pink-800', 'border-pink-200', 'dark:bg-pink-900', 'dark:text-pink-200', 'dark:border-pink-800', + 'bg-red-100', 'text-red-800', 'border-red-200', 'dark:bg-red-900', 'dark:text-red-200', 'dark:border-red-800', + 'bg-orange-100', 'text-orange-800', 'border-orange-200', 'dark:bg-orange-900', 'dark:text-orange-200', 'dark:border-orange-800', + 'bg-yellow-100', 'text-yellow-800', 'border-yellow-200', 'dark:bg-yellow-900', 'dark:text-yellow-200', 'dark:border-yellow-800', + 'bg-green-100', 'text-green-800', 'border-green-200', 'dark:bg-green-900', 'dark:text-green-200', 'dark:border-green-800', + 'bg-emerald-100', 'text-emerald-800', 'border-emerald-200', 'dark:bg-emerald-900', 'dark:text-emerald-200', 'dark:border-emerald-800', + 'bg-cyan-100', 'text-cyan-800', 'border-cyan-200', 'dark:bg-cyan-900', 'dark:text-cyan-200', 'dark:border-cyan-800', + // Dark mode backgrounds + 'bg-gray-950', 'bg-gray-900', 'bg-gray-800', 'dark:bg-gray-950', 'dark:bg-gray-900', 'dark:bg-gray-800', + 'border-gray-800', 'dark:border-gray-800', 'border-gray-700', 'dark:border-gray-700' + ], theme: { extend: {}, }, diff --git a/apps/web/components/layout/page.component.tsx b/apps/web/components/layout/page.component.tsx index b26b791..fd5d1ad 100644 --- a/apps/web/components/layout/page.component.tsx +++ b/apps/web/components/layout/page.component.tsx @@ -61,7 +61,7 @@ export default function Page({ className={classNames( "max-w-7xl mx-auto py-4 sm:py-2 px-4 sm:px-6 lg:px-8", !subtitle && "py-4 sm:py-4", - !!title && tabs.length > 0 && "relative sm:pb-0 lg:pb-2" + !!title && tabs.length > 0 && "relative sm:pb-0" )} >
@@ -146,8 +146,8 @@ export default function Page({ : false}
- {!!title && tabs?.length > 0 && ( -
+ {!!title && tabs?.length > 0 ? ( +
- )} + ) : null}
diff --git a/apps/web/pages/pages/[page_id]/index.tsx b/apps/web/pages/pages/[page_id]/index.tsx index 6115de5..11c35b1 100644 --- a/apps/web/pages/pages/[page_id]/index.tsx +++ b/apps/web/pages/pages/[page_id]/index.tsx @@ -116,6 +116,22 @@ export default function PageDetail({ const [showWidgetCode, setShowWidgetCode] = useState(false); + const viewTabs = useMemo( + () => [ + { + name: "Changelog", + current: true, + href: `/pages/${page_id}`, + }, + { + name: "Roadmap", + current: false, + href: `/pages/${page_id}/roadmap`, + }, + ], + [page_id] + ); + const statusFilters = useMemo( () => [ { @@ -287,6 +303,7 @@ export default function PageDetail({ showBackButton={true} backRoute={ROUTES.PAGES} containerClassName="lg:pb-0" + tabs={viewTabs} buttons={ { + console.error("Failed to get page", e); + return null; + }); + + if (!page) { + return { + notFound: true, + }; + } + + const settings = await createOrRetrievePageSettings(String(page_id)); + + // Fetch the specific roadmap board + const { data: board, error: boardError } = await supabase + .from("roadmap_boards") + .select("*") + .eq("id", board_id) + .eq("page_id", page_id) + .single(); + + if (boardError || !board) { + console.error("Failed to fetch roadmap board", boardError); + return { + notFound: true, + }; + } + + // Fetch columns for this board + const { data: columns, error: columnsError } = await supabase + .from("roadmap_columns") + .select("*") + .eq("board_id", board.id) + .order("position", { ascending: true }); + + if (columnsError) { + console.error("Failed to fetch columns", columnsError); + } + + // Fetch items for this board with category information and vote counts + const { data: items, error: itemsError } = await supabase + .from("roadmap_items") + .select( + ` + *, + roadmap_categories ( + id, + name, + color + ), + roadmap_votes ( + id + ) + ` + ) + .eq("board_id", board.id) + .order("position", { ascending: true }); + + if (itemsError) { + console.error("Failed to fetch items", itemsError); + } + + // Fetch categories for this board + const { data: categories, error: categoriesError } = await supabase + .from("roadmap_categories") + .select("*") + .eq("board_id", board.id) + .order("created_at", { ascending: true }); + + if (categoriesError) { + console.error("Failed to fetch categories", categoriesError); + } + + return { + props: { + page_id, + page, + settings, + board, + columns: columns || [], + items: items || [], + categories: categories || [], + }, + }; +} + +export default function RoadmapBoardDetails({ + page, + page_id, + settings: serverSettings, + board, + columns, + items, + categories, +}: InferGetServerSidePropsType) { + const router = useRouter(); + const { user } = useUserData(); + + const { settings: clientSettings } = usePageSettings(page_id, false); + const isPageOwner = useMemo(() => page?.user_id === user?.id, [page, user]); + + const settings = useMemo( + () => clientSettings ?? serverSettings, + [serverSettings, clientSettings] + ); + + const { supabase } = useUserData(); + + // Item states + const [selectedItem, setSelectedItem] = useState(null); + const [showItemModal, setShowItemModal] = useState(false); + const [selectedColumnId, setSelectedColumnId] = useState(null); + const [editingItem, setEditingItem] = useState(null); + + // Form states + const [itemForm, setItemForm] = useState({ + title: "", + description: "", + category_id: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formErrors, setFormErrors] = useState({}); + + // Data states + const [boardItems, setBoardItems] = useState(items); + const [boardCategories] = useState(categories); + + // Drag and drop states + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [dragOverPosition, setDragOverPosition] = useState(null); + + // Organize items by column + const itemsByColumn = useMemo(() => { + const organized = {}; + columns.forEach((column) => { + organized[column.id] = boardItems + .filter((item) => item.column_id === column.id) + .sort((a, b) => (a.position || 0) - (b.position || 0)); + }); + return organized; + }, [columns, boardItems]); + + // CRUD Functions + const handleAddItem = (columnId: string) => { + setSelectedColumnId(columnId); + setEditingItem(null); + setItemForm({ + title: "", + description: "", + category_id: boardCategories[0]?.id || "", + }); + setFormErrors({}); + setShowItemModal(true); + }; + + const handleEditItem = (item) => { + setEditingItem(item); + setSelectedColumnId(item.column_id); + setItemForm({ + title: item.title, + description: item.description || "", + category_id: item.category_id || "", + }); + setFormErrors({}); + setShowItemModal(true); + }; + + const handleDeleteItem = async (itemId: string) => { + if (!confirm("Are you sure you want to delete this item?")) return; + + try { + const { error } = await supabase + .from("roadmap_items") + .delete() + .eq("id", itemId); + + if (error) throw error; + + setBoardItems((prev) => prev.filter((item) => item.id !== itemId)); + } catch (error) { + console.error("Error deleting item:", error); + alert("Failed to delete item"); + } + }; + + const validateForm = () => { + const errors = {}; + if (!itemForm.title.trim()) errors.title = "Title is required"; + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmitItem = async (e) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + try { + if (editingItem) { + // Update existing item + const { data, error } = await supabase + .from("roadmap_items") + .update({ + title: itemForm.title.trim(), + description: itemForm.description.trim() || null, + category_id: itemForm.category_id || null, + }) + .eq("id", editingItem.id) + .select( + ` + *, + roadmap_categories ( + id, + name, + color + ) + ` + ) + .single(); + + if (error) throw error; + + setBoardItems((prev) => + prev.map((item) => (item.id === editingItem.id ? data : item)) + ); + } else { + // Create new item + const columnItems = itemsByColumn[selectedColumnId] || []; + const maxPosition = + columnItems.length > 0 + ? Math.max(...columnItems.map((item) => item.position || 0)) + : 0; + + const { data, error } = await supabase + .from("roadmap_items") + .insert({ + board_id: board.id, + column_id: selectedColumnId, + title: itemForm.title.trim(), + description: itemForm.description.trim() || null, + category_id: itemForm.category_id || null, + position: maxPosition + 1, + }) + .select( + ` + *, + roadmap_categories ( + id, + name, + color + ) + ` + ) + .single(); + + if (error) throw error; + + setBoardItems((prev) => [...prev, data]); + } + + setShowItemModal(false); + } catch (error) { + console.error("Error saving item:", error); + setFormErrors({ general: "Failed to save item" }); + } finally { + setIsSubmitting(false); + } + }; + + const closeModal = () => { + setShowItemModal(false); + setEditingItem(null); + setFormErrors({}); + }; + + // Drag and Drop Functions + const handleDragStart = (e, item) => { + if (!isPageOwner) return; + setDraggedItem(item); + e.dataTransfer.effectAllowed = "move"; + e.target.style.opacity = "0.5"; + }; + + const handleDragEnd = (e) => { + e.target.style.opacity = "1"; + setDraggedItem(null); + setDragOverColumn(null); + setDragOverPosition(null); + }; + + const handleDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDragEnter = (e, columnId) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = (e) => { + // Only clear drag over if leaving the column container + if (!e.currentTarget.contains(e.relatedTarget)) { + setDragOverColumn(null); + setDragOverPosition(null); + } + }; + + const handleItemDragOver = (e, columnId, itemId, position) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverColumn(columnId); + setDragOverPosition({ itemId, position }); + }; + + const handleDrop = async (e, targetColumnId) => { + e.preventDefault(); + setDragOverColumn(null); + const currentDragOverPosition = dragOverPosition; + setDragOverPosition(null); + + if (!draggedItem || !isPageOwner) return; + + try { + const sourceColumnId = draggedItem.column_id; + const sourceColumnItems = itemsByColumn[sourceColumnId] || []; + const targetColumnItems = itemsByColumn[targetColumnId] || []; + + if (sourceColumnId === targetColumnId) { + // Same column reordering + if (!currentDragOverPosition) { + setDraggedItem(null); + return; + } + + const draggedIndex = sourceColumnItems.findIndex( + (item) => item.id === draggedItem.id + ); + const targetIndex = sourceColumnItems.findIndex( + (item) => item.id === currentDragOverPosition.itemId + ); + + if ( + draggedIndex === -1 || + targetIndex === -1 || + draggedIndex === targetIndex + ) { + setDraggedItem(null); + return; + } + + // Create new ordered array + const reorderedItems = [...sourceColumnItems]; + const [draggedItemData] = reorderedItems.splice(draggedIndex, 1); + + // Calculate new insert position + let insertIndex = targetIndex; + if (currentDragOverPosition.position === "after") { + insertIndex = targetIndex + 1; + } + if ( + draggedIndex < targetIndex && + currentDragOverPosition.position === "before" + ) { + insertIndex = targetIndex - 1; + } + if ( + draggedIndex < targetIndex && + currentDragOverPosition.position === "after" + ) { + insertIndex = targetIndex; + } + + reorderedItems.splice(insertIndex, 0, draggedItemData); + + // Update local state first for immediate UI feedback + setBoardItems((prev) => { + return prev.map((item) => { + const updatedIndex = reorderedItems.findIndex( + (reorderedItem) => reorderedItem.id === item.id + ); + if (updatedIndex !== -1) { + return { ...item, position: updatedIndex + 1 }; + } + return item; + }); + }); + + // Update database with a single batch operation + // First, temporarily set all positions to very high numbers to avoid conflicts + const tempUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: 1000 + index }) + .eq("id", item.id) + ); + await Promise.all(tempUpdates); + + // Then set final positions + const finalUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: index + 1 }) + .eq("id", item.id) + ); + await Promise.all(finalUpdates); + } else { + // Moving between different columns + let newPosition = 1; + + if (!currentDragOverPosition) { + // Drop at end of target column + newPosition = + targetColumnItems.length > 0 + ? Math.max( + ...targetColumnItems.map((item) => item.position || 0) + ) + 1 + : 1; + } else { + // Drop at specific position + const targetItem = targetColumnItems.find( + (item) => item.id === currentDragOverPosition.itemId + ); + if (targetItem) { + if (currentDragOverPosition.position === "before") { + newPosition = targetItem.position; + // Shift items down that are at or after this position + const itemsToShift = targetColumnItems.filter( + (item) => item.position >= targetItem.position + ); + if (itemsToShift.length > 0) { + const shiftPromises = itemsToShift.map((item) => + supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id) + ); + await Promise.all(shiftPromises); + } + } else { + newPosition = targetItem.position + 1; + // Shift items down that are after this position + const itemsToShift = targetColumnItems.filter( + (item) => item.position > targetItem.position + ); + if (itemsToShift.length > 0) { + const shiftPromises = itemsToShift.map((item) => + supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id) + ); + await Promise.all(shiftPromises); + } + } + } else { + newPosition = targetColumnItems.length + 1; + } + } + + // Update the dragged item + const { error } = await supabase + .from("roadmap_items") + .update({ + column_id: targetColumnId, + position: newPosition, + }) + .eq("id", draggedItem.id); + + if (error) throw error; + + // Update local state + setBoardItems((prev) => + prev.map((item) => { + if (item.id === draggedItem.id) { + return { + ...item, + column_id: targetColumnId, + position: newPosition, + }; + } + // Update positions of shifted items in target column + if (item.column_id === targetColumnId && currentDragOverPosition) { + const targetItem = targetColumnItems.find( + (ti) => ti.id === currentDragOverPosition.itemId + ); + if (targetItem) { + if ( + currentDragOverPosition.position === "before" && + item.position >= targetItem.position + ) { + return { ...item, position: item.position + 1 }; + } + if ( + currentDragOverPosition.position === "after" && + item.position > targetItem.position + ) { + return { ...item, position: item.position + 1 }; + } + } + } + return item; + }) + ); + } + } catch (error) { + console.error("Error moving item:", error); + alert("Failed to move item"); + } + + setDraggedItem(null); + }; + + if (!page_id || !board) return null; + + return ( + <> + } + onClick={() => + (window.location.href = `/pages/${page_id}/roadmap/${board.id}/settings`) + } + /> + ) + } + > + {/* Kanban Board */} +
+
+
+ {columns.map((column) => ( +
handleDragEnter(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > + {/* Stage Header */} +
+
+

+ {column.name} + {dragOverColumn === column.id && ( + + + + + + )} +

+ + {itemsByColumn[column.id]?.length || 0} + +
+
+ + {/* Stage Items */} +
+ {itemsByColumn[column.id]?.map((item, itemIndex) => ( +
+ {/* Drop zone before item */} +
+ handleItemDragOver(e, column.id, item.id, "before") + } + onDrop={(e) => handleDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "before" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + + {/* Item */} +
handleDragStart(e, item)} + onDragEnd={handleDragEnd} + className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-all group mb-2 ${ + isPageOwner ? "cursor-move" : "" + } ${draggedItem?.id === item.id ? "opacity-50" : ""}`} + > +
+

setSelectedItem(item)} + > + {item.title} +

+ {isPageOwner && ( +
+ + +
+ )} +
+ + + {/* Bottom row with category and votes */} +
+
+ {item.roadmap_categories && ( + + {item.roadmap_categories.name} + + )} +
+
+ + + + {item.roadmap_votes?.length || 0} +
+
+
+ + {/* Drop zone after the last item */} + {itemIndex === + (itemsByColumn[column.id]?.length || 0) - 1 && ( +
+ handleItemDragOver(e, column.id, item.id, "after") + } + onDrop={(e) => handleDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "after" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} +
+ ))} + + {/* Drop zone at the end when column has items */} + {itemsByColumn[column.id]?.length > 0 && ( +
handleDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverColumn === column.id && !dragOverPosition + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} + + {/* Add Item Button */} + {isPageOwner && ( + + )} +
+
+ ))} +
+
+
+ + {/* Empty state when no columns */} + {columns.length === 0 && ( +
+ +

+ No stages configured +

+

+ This board doesn't have any stages set up yet. +

+
+ )} + + + {/* Item Create/Edit Modal */} + + + +
+ + +
+
+ + + + {editingItem ? "Edit Item" : "Add New Item"} + + +
+ {formErrors.general && ( +
+
+ {formErrors.general} +
+
+ )} + +
+ + + setItemForm((prev) => ({ + ...prev, + title: e.target.value, + })) + } + className="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-700 dark:text-gray-300 sm:text-sm" + placeholder="Enter item title..." + /> + {formErrors.title && ( +

+ {formErrors.title} +

+ )} +
+ +
+ +