diff --git a/apps/page/components/page-header.tsx b/apps/page/components/page-header.tsx index 448fbfa..7c2e875 100644 --- a/apps/page/components/page-header.tsx +++ b/apps/page/components/page-header.tsx @@ -1,14 +1,22 @@ 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 { PageRoadmap } from "../lib/data"; import OptionalLink from "./optional-link"; export default function PageHeader({ page, settings, + roadmaps = [], + isRoadmapPage = false, }: { page: IPage; settings: IPageSettings; + roadmaps?: PageRoadmap[]; + isRoadmapPage?: boolean; }) { return ( <> @@ -33,40 +41,120 @@ 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..9f2380f 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/hooks/usePageTheme.ts b/apps/page/hooks/usePageTheme.ts new file mode 100644 index 0000000..6f9e013 --- /dev/null +++ b/apps/page/hooks/usePageTheme.ts @@ -0,0 +1,12 @@ +import { useTheme } from "next-themes"; +import { useEffect } from "react"; + +export function usePageTheme(colorScheme?: string) { + const { setTheme } = useTheme(); + + useEffect(() => { + if (colorScheme && colorScheme !== "auto") { + setTheme(colorScheme); + } + }, [colorScheme, setTheme]); +} \ No newline at end of file diff --git a/apps/page/lib/data.ts b/apps/page/lib/data.ts index aac06fe..7686d4f 100644 --- a/apps/page/lib/data.ts +++ b/apps/page/lib/data.ts @@ -1,6 +1,13 @@ import { supabaseAdmin } from "@changes-page/supabase/admin"; import { Database } from "@changes-page/supabase/types"; -import { IPage, IPageSettings, IPost } from "@changes-page/supabase/types/page"; +import { + IPage, + IPageSettings, + IPost, + IRoadmapBoard, + IRoadmapColumn, + IRoadmapItem, +} from "@changes-page/supabase/types/page"; import { sanitizeCss } from "./css"; const PAGINATION_LIMIT = 50; @@ -70,6 +77,11 @@ export const BLACKLISTED_SLUGS = [ "press-kit", ]; +export type PageRoadmap = Pick< + IRoadmapBoard, + "id" | "title" | "slug" | "description" +>; + const postSelectParams = "id,title,content,tags,publication_date,updated_at,created_at,allow_reactions"; @@ -102,9 +114,11 @@ function translateHostToPageIdentifier(host: string): { }; } -async function fetchRenderData( - site: string -): Promise<{ page: IPage | null; settings: IPageSettings | null }> { +async function fetchRenderData(site: string): Promise<{ + page: IPage | null; + settings: IPageSettings | null; + roadmaps: PageRoadmap[]; +}> { const pageSelect = `id,title,description,type,url_slug,user_id`; const settingsSelect = `page_id,page_logo,cover_image,product_url,twitter_url,github_url,instagram_url,facebook_url,linkedin_url,youtube_url,tiktok_url,app_store_url,play_store_url,pinned_post_id,whitelabel,hide_search_engine,email_notifications,rss_notifications,color_scheme,custom_css`; @@ -114,6 +128,7 @@ async function fetchRenderData( const emptyResponse = { page: null, settings: null, + roadmaps: [], }; try { @@ -192,16 +207,30 @@ async function fetchRenderData( } } - return { - page: page as IPage, - settings: settings as IPageSettings, - }; + const [{ data: roadmaps }, isSubscriptionActive] = await Promise.all([ + supabaseAdmin + .from("roadmap_boards") + .select("id, title, slug, description") + .eq("page_id", page.id) + .eq("is_public", true) + .order("created_at", { ascending: true }), + isPageSubscriptionActive(page.user_id), + ]); + + return isSubscriptionActive + ? { + page: page as IPage, + settings: settings as IPageSettings, + roadmaps: roadmaps ?? [], + } + : emptyResponse; } catch (e) { console.log("[fetchRenderData] error", e); return { page: null, settings: null, + roadmaps: [], }; } } @@ -328,7 +357,7 @@ async function fetchPostById( }; } -async function isSubscriptionActive(user_id: string): Promise { +async function isPageSubscriptionActive(user_id: string): Promise { const { data: isSubscriptionActive, error } = await supabaseAdmin .rpc< "is_subscription_active", @@ -351,12 +380,72 @@ async function isSubscriptionActive(user_id: string): Promise { return isSubscriptionActive ?? true; } +export type RoadmapItemWithCategory = IRoadmapItem & { + roadmap_categories: { + id: string; + name: string; + color: string | null; + } | null; +}; + +async function getRoadmapBySlug( + pageId: string, + slug: string +): Promise<{ + board: IRoadmapBoard | null; + columns: IRoadmapColumn[]; + items: RoadmapItemWithCategory[] | null; +}> { + const { data: board, error } = await supabaseAdmin + .from("roadmap_boards") + .select("*") + .eq("page_id", pageId) + .eq("slug", slug) + .eq("is_public", true) + .single(); + + if (error) { + console.error("Error fetching roadmap by slug:", error); + return { board, columns: [], items: [] }; + } + + const { data: columns, error: columnsError } = await supabaseAdmin + .from("roadmap_columns") + .select("*") + .eq("board_id", board.id) + .order("position", { ascending: true }); + if (columnsError) { + console.error("Error fetching roadmap columns:", columnsError); + return { board, columns: [], items: [] }; + } + + 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); + } + + return { board, columns, items }; +} + export { fetchPostById, fetchPosts, fetchRenderData, - isSubscriptionActive, + getRoadmapBySlug, PAGINATION_LIMIT, - translateHostToPageIdentifier + translateHostToPageIdentifier, }; - diff --git a/apps/page/pages/_sites/[site]/index.tsx b/apps/page/pages/_sites/[site]/index.tsx index 2ed083f..10f3862 100644 --- a/apps/page/pages/_sites/[site]/index.tsx +++ b/apps/page/pages/_sites/[site]/index.tsx @@ -1,18 +1,18 @@ import { IPage, IPageSettings, IPost } from "@changes-page/supabase/types/page"; import { Timeline } from "@changes-page/ui"; import classNames from "classnames"; -import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import Footer from "../../../components/footer"; import PageHeader from "../../../components/page-header"; import Post from "../../../components/post"; import SeoTags from "../../../components/seo-tags"; import SubscribePrompt from "../../../components/subscribe-prompt"; +import { usePageTheme } from "../../../hooks/usePageTheme"; import { BLACKLISTED_SLUGS, fetchPosts, fetchRenderData, - isSubscriptionActive, + PageRoadmap, } from "../../../lib/data"; export default function Index({ @@ -20,20 +20,15 @@ export default function Index({ page, postsCount, settings, + roadmaps, }: { page: IPage; settings: IPageSettings; posts: IPost[]; postsCount: number; + roadmaps: PageRoadmap[]; }) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); const [posts, setPosts] = useState(initialPosts); const [loadingMore, setLoadingMore] = useState(false); @@ -71,7 +66,7 @@ export default function Index({
- + {(settings?.email_notifications || settings?.rss_notifications) && ( @@ -166,9 +161,9 @@ export async function getServerSideProps({ }; } - const { page, settings } = await fetchRenderData(site); + const { page, settings, roadmaps } = await fetchRenderData(site); - if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { + if (!page || !settings) { return { notFound: true, }; @@ -185,6 +180,7 @@ export async function getServerSideProps({ posts, postsCount, settings, + roadmaps, }, }; } diff --git a/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx b/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx index 7a84869..4e2a13f 100644 --- a/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx +++ b/apps/page/pages/_sites/[site]/notifications/confirm-email-subscription.tsx @@ -1,8 +1,7 @@ import { IPage, IPageSettings } from "@changes-page/supabase/types/page"; import { CheckCircleIcon } from "@heroicons/react/outline"; import type { GetServerSideProps } from "next"; -import { useTheme } from "next-themes"; -import { useEffect } from "react"; +import { usePageTheme } from "../../../../hooks/usePageTheme"; import PageHeader from "../../../../components/page-header"; import SeoTags from "../../../../components/seo-tags"; import { fetchRenderData } from "../../../../lib/data"; @@ -16,14 +15,7 @@ export default function Index({ page: IPage; settings: IPageSettings; }) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); return ( <> diff --git a/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx b/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx index 1a9b0bc..e2fad9e 100644 --- a/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx +++ b/apps/page/pages/_sites/[site]/post/[postId]/[slug].tsx @@ -2,20 +2,15 @@ import { Timeline } from "@changes-page/ui"; import { convertMarkdownToPlainText } from "@changes-page/utils"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/solid"; import { InferGetServerSidePropsType } from "next"; -import { useTheme } from "next-themes"; import Link from "next/link"; -import { useEffect } from "react"; import { validate as uuidValidate } from "uuid"; import Footer from "../../../../../components/footer"; import PageHeader from "../../../../../components/page-header"; import Post from "../../../../../components/post"; import SeoTags from "../../../../../components/seo-tags"; import SubscribePrompt from "../../../../../components/subscribe-prompt"; -import { - fetchPostById, - fetchRenderData, - isSubscriptionActive, -} from "../../../../../lib/data"; +import { usePageTheme } from "../../../../../hooks/usePageTheme"; +import { fetchPostById, fetchRenderData } from "../../../../../lib/data"; import { getPageUrl, getPostUrl } from "../../../../../lib/url"; export default function Index({ @@ -25,14 +20,7 @@ export default function Index({ settings, plainTextContent, }: InferGetServerSidePropsType) { - const { setTheme } = useTheme(); - - useEffect(() => { - if (settings?.color_scheme != "auto") { - setTheme(settings?.color_scheme); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings?.color_scheme]); + usePageTheme(settings?.color_scheme); return ( <> @@ -120,8 +108,7 @@ export async function getServerSideProps({ } const { page, settings } = await fetchRenderData(site); - - if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { + if (!page || !settings) { return { notFound: true, }; 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..9e87aa7 --- /dev/null +++ b/apps/page/pages/_sites/[site]/roadmap/[roadmap_slug].tsx @@ -0,0 +1,571 @@ +import { + IPage, + IPageSettings, + IRoadmapBoard, + IRoadmapColumn, +} 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 { Fragment, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import Footer from "../../../../components/footer"; +import PageHeader from "../../../../components/page-header"; +import SeoTags from "../../../../components/seo-tags"; +import { usePageTheme } from "../../../../hooks/usePageTheme"; +import { + BLACKLISTED_SLUGS, + fetchRenderData, + getRoadmapBySlug, + PageRoadmap, + RoadmapItemWithCategory, +} from "../../../../lib/data"; +import { getPageUrl } from "../../../../lib/url"; +import { httpPost } from "../../../../utils/http"; + +type RoadmapItem = RoadmapItemWithCategory & { + vote_count?: number; +}; + +export default function RoadmapPage({ + page, + settings, + board, + columns, + items, + roadmaps, +}: { + page: IPage; + settings: IPageSettings; + board: IRoadmapBoard; + columns: IRoadmapColumn[]; + items: RoadmapItem[]; + roadmaps: PageRoadmap[]; +}) { + usePageTheme(settings?.color_scheme); + + const [selectedItem, setSelectedItem] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [votes, setVotes] = useState< + Record + >({}); + const [votingItems, setVotingItems] = useState>(new Set()); + + 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) => { + if (votingItems.has(itemId)) return; + + setVotingItems((prev) => new Set(prev).add(itemId)); + + 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 data = await httpPost({ + url: "/api/roadmap/vote", + data: { item_id: itemId }, + }); + + setVotes((prev) => ({ + ...prev, + [itemId]: { + count: data.vote_count || 0, + voted: newVotedState, + }, + })); + } catch (error) { + console.error("Error voting:", 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 data = await httpPost({ + url: "/api/roadmap/votes", + data: { item_ids: items.map((item) => item.id) }, + }); + + // Transform API response to match expected frontend structure + const transformedVotes: Record< + string, + { count: number; voted: boolean } + > = {}; + 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} + {item.description && item.description.trim() && ( + + + + )} +

+ + {/* 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 */} + + + +
+ + +
+
+ +
+ + +
+ {/* Column Divider */} +
+ + {/* Left side - Content */} +
+

+ {selectedItem?.title} +

+ {selectedItem?.description && ( +
+
+ + {selectedItem.description} + +
+
+ )} +
+ + {/* Right side - Metadata */} +
+ {/* Votes */} +
+ + Votes + + +
+ + {/* Status (Column) */} + {selectedItem?.column_id && ( +
+ + Status + + + {columns.find( + (col) => col.id === selectedItem.column_id + )?.name || "Unknown"} + +
+ )} + + {/* Category */} + {selectedItem?.roadmap_categories && ( +
+ + Category + + + {selectedItem.roadmap_categories.name} + +
+ )} + + {/* Board */} +
+ + Board + + + {board.title} + +
+ + {/* Created Date */} + {selectedItem?.created_at && ( +
+ + Created + + + {new Date( + selectedItem.created_at + ).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + +
+ )} +
+
+
+
+
+
+
+
+
+ + ); +} + +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, roadmaps } = await fetchRenderData(site); + + if (!page || !settings) { + return { + notFound: true, + }; + } + + const { board, columns, items } = await getRoadmapBySlug( + page.id, + roadmap_slug + ); + + if (!board) { + return { + notFound: true, + }; + } + + return { + props: { + page, + settings, + board, + columns: columns || [], + items: items || [], + roadmaps: roadmaps || [], + }, + }; +} diff --git a/apps/page/pages/api/pa/view.ts b/apps/page/pages/api/pa/view.ts index 23acd7e..499d9d8 100644 --- a/apps/page/pages/api/pa/view.ts +++ b/apps/page/pages/api/pa/view.ts @@ -26,7 +26,7 @@ async function pageAnalyticsView( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/post/react.ts b/apps/page/pages/api/post/react.ts index b985111..cc3e182 100644 --- a/apps/page/pages/api/post/react.ts +++ b/apps/page/pages/api/post/react.ts @@ -13,7 +13,7 @@ export default async function reactToPost( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/post/reactions.ts b/apps/page/pages/api/post/reactions.ts index fa2fa33..5305505 100644 --- a/apps/page/pages/api/post/reactions.ts +++ b/apps/page/pages/api/post/reactions.ts @@ -13,7 +13,7 @@ export default async function getPostReactions( visitor_id = v4(); res.setHeader( "Set-Cookie", - `cp_pa_vid=${visitor_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` ); } diff --git a/apps/page/pages/api/roadmap/vote.ts b/apps/page/pages/api/roadmap/vote.ts new file mode 100644 index 0000000..48dfc67 --- /dev/null +++ b/apps/page/pages/api/roadmap/vote.ts @@ -0,0 +1,100 @@ +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=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Ensure item exists and belongs to a public board + const { data: itemCheck, error: itemCheckError } = await supabaseAdmin + .from("roadmap_items") + .select("id, board_id, roadmap_boards!inner(is_public)") + .eq("id", item_id) + .eq("roadmap_boards.is_public", true) + .maybeSingle(); + + if (itemCheckError || !itemCheck) { + return res + .status(404) + .json({ ok: false, error: "Item not found or not public" }); + } + + // 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 { count, error: countError } = await supabaseAdmin + .from("roadmap_votes") + .select("id", { count: "exact", head: true }) + .eq("item_id", item_id); + + if (countError) { + console.error("voteOnRoadmapItem [Count Error]", countError); + } + + res.status(200).json({ + ok: true, + vote_count: count || 0, + }); + } catch (e: Error | any) { + console.log("voteOnRoadmapItem [Error]", e); + res.status(500).json({ ok: false, error: "Internal server error" }); + } +} + diff --git a/apps/page/pages/api/roadmap/votes.ts b/apps/page/pages/api/roadmap/votes.ts new file mode 100644 index 0000000..db0ea21 --- /dev/null +++ b/apps/page/pages/api/roadmap/votes.ts @@ -0,0 +1,121 @@ +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") { + res.setHeader("Allow", "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: {} }); + } + + // De-duplicate to keep queries lean + const distinctItemIds: string[] = Array.from(new Set(item_ids)); + if (distinctItemIds.length === 0) { + return res.status(200).json({ ok: true, votes: {} }); + } + + if (!visitor_id) { + visitor_id = v4(); + res.setHeader( + "Set-Cookie", + `cp_pa_vid=${visitor_id}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000` + ); + } + + try { + // Use a more efficient approach: get counts per item using a GROUP BY-like query + const voteCountPromises = distinctItemIds.map((itemId) => + supabaseAdmin + .from("roadmap_votes") + .select("id", { count: "exact", head: true }) + .eq("item_id", itemId) + ); + + const [userVoteResult, ...voteCountResults] = await Promise.all([ + supabaseAdmin + .from("roadmap_votes") + .select("item_id") + .in("item_id", distinctItemIds) + .eq("visitor_id", visitor_id), + ...voteCountPromises, + ]); + + if (userVoteResult.error) { + console.error( + "getBulkRoadmapItemVotes [User Error]", + userVoteResult.error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + + // Check for any errors in vote count queries + for (let i = 0; i < voteCountResults.length; i++) { + if (voteCountResults[i].error) { + console.error( + "getBulkRoadmapItemVotes [Count Error for %s]", + distinctItemIds[i], + voteCountResults[i].error + ); + return res.status(500).json({ ok: false, votes: {} }); + } + } + + // Create vote counts map from the database counts + const voteCountsMap: Record = {}; + distinctItemIds.forEach((itemId, index) => { + voteCountsMap[itemId] = voteCountResults[index].count || 0; + }); + + 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..a8d3da2 100644 --- a/apps/page/tailwind.config.js +++ b/apps/page/tailwind.config.js @@ -7,6 +7,17 @@ module.exports = { "./components/**/*.{js,ts,jsx,tsx}", "./node_modules/@changes-page/ui/components/**/*.{js,ts,jsx,tsx}", ], + safelist: [ + { + // emit base + dark variants for the used families and shades (incl. preview 500) + pattern: + /(bg|text|border)-(blue|indigo|purple|pink|red|orange|yellow|green|emerald|cyan)-(100|200|500|800|900)/, + variants: ["dark"], + }, + // Gray backgrounds/borders used globally + { pattern: /bg-gray-(800|900|950)/, variants: ["dark"] }, + { pattern: /border-gray-(700|800)/, variants: ["dark"] }, + ], theme: { extend: {}, }, diff --git a/apps/web/components/layout/page.component.tsx b/apps/web/components/layout/page.component.tsx index b26b791..ae7d530 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,9 +146,9 @@ export default function Page({ : false}
- {!!title && tabs?.length > 0 && ( -
-
+ {!!title && tabs?.length > 0 ? ( +
+
@@ -156,7 +156,7 @@ export default function Page({ id="current-tab" name="current-tab" className="block w-full rounded-md dark:bg-gray-800 dark:text-gray-200 border-gray-300 dark:border-gray-700 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" - defaultValue={tabs.find((tab) => tab.current)?.name} + defaultValue={tabs.find((tab) => tab.current)?.href} onChange={(e) => router.push(e?.target?.value)} > {tabs.map((tab) => ( @@ -186,7 +186,7 @@ export default function Page({
- )} + ) : null}
diff --git a/apps/web/components/roadmap/RoadmapBoard.tsx b/apps/web/components/roadmap/RoadmapBoard.tsx new file mode 100644 index 0000000..c4b1b38 --- /dev/null +++ b/apps/web/components/roadmap/RoadmapBoard.tsx @@ -0,0 +1,121 @@ +import { + IRoadmapBoard, + IRoadmapCategory, + IRoadmapColumn, +} from "@changes-page/supabase/types/page"; +import { useMemo, useState } from "react"; +import RoadmapColumn from "./RoadmapColumn"; +import RoadmapItemModal from "./RoadmapItemModal"; +import { useRoadmapDragDrop } from "./hooks/useRoadmapDragDrop"; +import { useRoadmapItems } from "./hooks/useRoadmapItems"; +import { ItemsByColumn, RoadmapItemWithRelations } from "./types"; + +export default function RoadmapBoard({ + board, + columns, + items, + categories, +}: { + board: IRoadmapBoard; + columns: IRoadmapColumn[]; + items: RoadmapItemWithRelations[]; + categories: IRoadmapCategory[]; +}) { + const [boardItems, setBoardItems] = useState(items); + + const itemsByColumn: ItemsByColumn = useMemo(() => { + const organized: ItemsByColumn = {}; + 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]); + + const dragDropHandlers = useRoadmapDragDrop({ + itemsByColumn, + setBoardItems, + }); + + const itemHandlers = useRoadmapItems({ + board, + categories, + itemsByColumn, + }); + + return ( + <> +
+
+
+ {columns.map((column) => ( + + itemHandlers.handleDeleteItem(itemId, setBoardItems) + } + onDragStart={dragDropHandlers.handleDragStart} + onDragEnd={dragDropHandlers.handleDragEnd} + onDragOver={dragDropHandlers.handleDragOver} + onDragEnter={dragDropHandlers.handleDragEnter} + onDragLeave={dragDropHandlers.handleDragLeave} + onDrop={dragDropHandlers.handleDrop} + onItemDragOver={dragDropHandlers.handleItemDragOver} + draggedItem={dragDropHandlers.draggedItem} + dragOverColumn={dragDropHandlers.dragOverColumn} + dragOverPosition={dragDropHandlers.dragOverPosition} + /> + ))} +
+
+
+ + {columns.length === 0 && ( +
+ +

+ No stages configured +

+

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

+
+ )} + + itemHandlers.handleSubmitItem(e, setBoardItems)} + itemForm={itemHandlers.itemForm} + setItemForm={itemHandlers.setItemForm} + formErrors={itemHandlers.formErrors} + isSubmitting={itemHandlers.isSubmitting} + editingItem={itemHandlers.editingItem} + categories={categories} + board={board} + /> + + ); +} diff --git a/apps/web/components/roadmap/RoadmapColumn.tsx b/apps/web/components/roadmap/RoadmapColumn.tsx new file mode 100644 index 0000000..be3f41a --- /dev/null +++ b/apps/web/components/roadmap/RoadmapColumn.tsx @@ -0,0 +1,149 @@ +import { IRoadmapColumn } from "@changes-page/supabase/types/page"; +import { PlusIcon } from "@heroicons/react/solid"; +import RoadmapItem from "./RoadmapItem"; +import { DragOverPosition, RoadmapItemWithRelations } from "./types"; + +interface RoadmapColumnProps { + column: IRoadmapColumn; + items: RoadmapItemWithRelations[]; + onAddItem: (columnId: string) => void; + onEditItem: (item: RoadmapItemWithRelations) => void; + onDeleteItem: (itemId: string) => void; + onDragStart: (e: React.DragEvent, item: RoadmapItemWithRelations) => void; + onDragEnd: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (e: React.DragEvent, columnId: string) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent, columnId: string) => void; + onItemDragOver: ( + e: React.DragEvent, + columnId: string, + itemId: string, + position: "before" | "after" + ) => void; + draggedItem: RoadmapItemWithRelations | null; + dragOverColumn: string | null; + dragOverPosition: DragOverPosition | null; +} + +export default function RoadmapColumn({ + column, + items, + onAddItem, + onEditItem, + onDeleteItem, + onDragStart, + onDragEnd, + onDragOver, + onDragEnter, + onDragLeave, + onDrop, + onItemDragOver, + draggedItem, + dragOverColumn, + dragOverPosition, +}: RoadmapColumnProps) { + return ( +
onDragEnter(e, column.id)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, column.id)} + > +
+
+

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

+ + {items.length} + +
+
+ +
+ {items.map((item, itemIndex) => ( +
+
+ onItemDragOver(e, column.id, item.id, "before") + } + onDrop={(e) => onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "before" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + + + + {itemIndex === items.length - 1 && ( +
+ onItemDragOver(e, column.id, item.id, "after") + } + onDrop={(e) => onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverPosition?.itemId === item.id && + dragOverPosition?.position === "after" + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} +
+ ))} + + {items.length > 0 && ( +
onDrop(e, column.id)} + className={`h-1 transition-colors ${ + dragOverColumn === column.id && !dragOverPosition + ? "bg-indigo-400 dark:bg-indigo-600 rounded" + : "" + }`} + /> + )} + + +
+
+ ); +} diff --git a/apps/web/components/roadmap/RoadmapItem.tsx b/apps/web/components/roadmap/RoadmapItem.tsx new file mode 100644 index 0000000..905d664 --- /dev/null +++ b/apps/web/components/roadmap/RoadmapItem.tsx @@ -0,0 +1,102 @@ +import { getCategoryColorClasses } from "@changes-page/utils"; +import { PencilIcon, TrashIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; +import type React from "react"; +import { RoadmapItemWithRelations } from "./types"; + +interface RoadmapItemProps { + item: RoadmapItemWithRelations; + onEdit: (item: RoadmapItemWithRelations) => void; + onDelete: (itemId: string) => void; + onDragStart: (e: React.DragEvent, item: RoadmapItemWithRelations) => void; + onDragEnd: (e: React.DragEvent) => void; + isDragged: boolean; +} + +export default function RoadmapItem({ + item, + onEdit, + onDelete, + onDragStart, + onDragEnd, + isDragged, +}: RoadmapItemProps) { + return ( +
onDragStart(e, item)} + onDragEnd={onDragEnd} + className={classNames( + "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 cursor-move", + isDragged ? "opacity-50" : "" + )} + > +
+
+

+ {item.title} + {item.description && item.description.trim() && ( + + + + )} +

+
+
+ + +
+
+ +
+
+ {item.roadmap_categories && ( + + {item.roadmap_categories.name} + + )} +
+
+ + + + {item.roadmap_votes?.length || 0} +
+
+
+ ); +} diff --git a/apps/web/components/roadmap/RoadmapItemModal.tsx b/apps/web/components/roadmap/RoadmapItemModal.tsx new file mode 100644 index 0000000..0edb94c --- /dev/null +++ b/apps/web/components/roadmap/RoadmapItemModal.tsx @@ -0,0 +1,200 @@ +import { + IRoadmapBoard, + IRoadmapCategory, +} from "@changes-page/supabase/types/page"; +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment, useCallback } from "react"; +import MarkdownEditor from "../core/editor.component"; +import { FormErrors, ItemForm, RoadmapItemWithRelations } from "./types"; + +interface RoadmapItemModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; + itemForm: ItemForm; + setItemForm: React.Dispatch>; + formErrors: FormErrors; + isSubmitting: boolean; + editingItem: RoadmapItemWithRelations | null; + categories: IRoadmapCategory[]; + board: IRoadmapBoard; +} + +export default function RoadmapItemModal({ + isOpen, + onClose, + onSubmit, + itemForm, + setItemForm, + formErrors, + isSubmitting, + editingItem, + categories, + board, +}: RoadmapItemModalProps) { + const handleDescriptionChange = useCallback( + (value: string) => { + setItemForm((prev) => ({ + ...prev, + description: value, + })); + }, + [setItemForm] + ); + + return ( + + + +
+ + +
+
+ +
+ + +
+ {formErrors.general && ( +
+
+ {formErrors.general} +
+
+ )} + + {formErrors.title && ( +
+
+ {formErrors.title} +
+
+ )} + +
+
+ +
+
+ + setItemForm((prev) => ({ + ...prev, + title: e.target.value, + })) + } + className="w-full text-xl font-semibold leading-6 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none mb-6" + placeholder="Enter item title..." + /> + +
+
+ +
+ {categories.length > 0 && ( +
+ + Category + + +
+ )} + +
+ + Board + + + {board.title} + +
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts b/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts new file mode 100644 index 0000000..fc623d6 --- /dev/null +++ b/apps/web/components/roadmap/hooks/useRoadmapDragDrop.ts @@ -0,0 +1,289 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { useUserData } from "../../../utils/useUser"; +import { + DragOverPosition, + ItemsByColumn, + RoadmapItemWithRelations, +} from "../types"; + +export function useRoadmapDragDrop({ + itemsByColumn, + setBoardItems, +}: { + itemsByColumn: ItemsByColumn; + setBoardItems: Dispatch>; +}) { + const { supabase } = useUserData(); + const [draggedItem, setDraggedItem] = + useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [dragOverPosition, setDragOverPosition] = + useState(null); + + const handleDragStart = ( + e: React.DragEvent, + item: RoadmapItemWithRelations + ) => { + setDraggedItem(item); + e.dataTransfer.effectAllowed = "move"; + (e.target as HTMLElement).style.opacity = "0.5"; + }; + + const handleDragEnd = (e: React.DragEvent) => { + (e.target as HTMLElement).style.opacity = "1"; + setDraggedItem(null); + setDragOverColumn(null); + setDragOverPosition(null); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleDragEnter = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverColumn(null); + setDragOverPosition(null); + } + }; + + const handleItemDragOver = ( + e: React.DragEvent, + columnId: string, + itemId: string, + position: "before" | "after" + ) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverColumn(columnId); + setDragOverPosition({ itemId, position }); + }; + + const handleDrop = async (e: React.DragEvent, targetColumnId: string) => { + e.preventDefault(); + setDragOverColumn(null); + const currentDragOverPosition = dragOverPosition; + setDragOverPosition(null); + + if (!draggedItem) return; + + try { + const sourceColumnId = draggedItem.column_id; + const sourceColumnItems = itemsByColumn[sourceColumnId] || []; + const targetColumnItems = itemsByColumn[targetColumnId] || []; + + if (sourceColumnId === targetColumnId) { + await handleSameColumnReorder( + sourceColumnItems, + currentDragOverPosition + ); + } else { + await handleCrossColumnMove( + targetColumnItems, + targetColumnId, + currentDragOverPosition + ); + } + } catch (error) { + console.error("Error moving item:", error); + alert("Failed to move item"); + } + + setDraggedItem(null); + }; + + const handleSameColumnReorder = async ( + sourceColumnItems: RoadmapItemWithRelations[], + currentDragOverPosition: DragOverPosition | null + ) => { + if (!currentDragOverPosition || !draggedItem) { + 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 + ) { + return; + } + + const reorderedItems = [...sourceColumnItems]; + const [draggedItemData] = reorderedItems.splice(draggedIndex, 1); + + 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); + + 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; + }); + }); + + const tempUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: 1000 + index }) + .eq("id", item.id) + ); + await Promise.all(tempUpdates); + + const finalUpdates = reorderedItems.map((item, index) => + supabase + .from("roadmap_items") + .update({ position: index + 1 }) + .eq("id", item.id) + ); + await Promise.all(finalUpdates); + }; + + const handleCrossColumnMove = async ( + targetColumnItems: RoadmapItemWithRelations[], + targetColumnId: string, + currentDragOverPosition: DragOverPosition | null + ) => { + if (!draggedItem) return; + + let newPosition = 1; + + if (!currentDragOverPosition) { + newPosition = + targetColumnItems.length > 0 + ? Math.max(...targetColumnItems.map((item) => item.position || 0)) + 1 + : 1; + } else { + const targetItem = targetColumnItems.find( + (item) => item.id === currentDragOverPosition.itemId + ); + if (targetItem) { + if (currentDragOverPosition.position === "before") { + newPosition = targetItem.position; + const itemsToShift = targetColumnItems.filter( + (item) => item.position >= targetItem.position + ); + if (itemsToShift.length > 0) { + // Sort items in descending position order to avoid uniqueness conflicts + const sortedItems = itemsToShift.sort( + (a, b) => (b.position || 0) - (a.position || 0) + ); + for (const item of sortedItems) { + await supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id); + } + } + } else { + newPosition = targetItem.position + 1; + const itemsToShift = targetColumnItems.filter( + (item) => item.position > targetItem.position + ); + if (itemsToShift.length > 0) { + // Sort items in descending position order to avoid uniqueness conflicts + const sortedItems = itemsToShift.sort( + (a, b) => (b.position || 0) - (a.position || 0) + ); + for (const item of sortedItems) { + await supabase + .from("roadmap_items") + .update({ position: item.position + 1 }) + .eq("id", item.id); + } + } + } + } else { + newPosition = targetColumnItems.length + 1; + } + } + + const { error } = await supabase + .from("roadmap_items") + .update({ + column_id: targetColumnId, + position: newPosition, + }) + .eq("id", draggedItem.id); + + if (error) throw error; + + setBoardItems((prev) => + prev.map((item) => { + if (item.id === draggedItem.id) { + return { + ...item, + column_id: targetColumnId, + position: newPosition, + }; + } + 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; + }) + ); + }; + + return { + draggedItem, + dragOverColumn, + dragOverPosition, + handleDragStart, + handleDragEnd, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleItemDragOver, + handleDrop, + }; +} diff --git a/apps/web/components/roadmap/hooks/useRoadmapItems.ts b/apps/web/components/roadmap/hooks/useRoadmapItems.ts new file mode 100644 index 0000000..114f434 --- /dev/null +++ b/apps/web/components/roadmap/hooks/useRoadmapItems.ts @@ -0,0 +1,196 @@ +import { + IRoadmapBoard, + IRoadmapCategory, +} from "@changes-page/supabase/types/page"; +import { useState } from "react"; +import { useUserData } from "../../../utils/useUser"; +import { + FormErrors, + ItemForm, + ItemsByColumn, + RoadmapItemWithRelations, +} from "../types"; + +export function useRoadmapItems({ + board, + categories, + itemsByColumn, +}: { + board: IRoadmapBoard; + categories: IRoadmapCategory[]; + itemsByColumn: ItemsByColumn; +}) { + const { supabase } = useUserData(); + const [showItemModal, setShowItemModal] = useState(false); + const [selectedColumnId, setSelectedColumnId] = useState(null); + const [editingItem, setEditingItem] = + useState(null); + const [itemForm, setItemForm] = useState({ + title: "", + description: "", + category_id: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formErrors, setFormErrors] = useState({}); + + const handleAddItem = (columnId: string) => { + setSelectedColumnId(columnId); + setEditingItem(null); + setItemForm({ + title: "", + description: "", + category_id: categories[0]?.id || "", + }); + setFormErrors({}); + setShowItemModal(true); + }; + + const handleEditItem = (item: RoadmapItemWithRelations) => { + 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, + setBoardItems: React.Dispatch< + React.SetStateAction + > + ) => { + 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: FormErrors = {}; + if (!itemForm.title.trim()) { + errors.title = "Title is required"; + } + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmitItem = async ( + e: React.FormEvent, + setBoardItems: React.Dispatch< + React.SetStateAction + > + ) => { + e.preventDefault(); + if (!validateForm()) return; + + setIsSubmitting(true); + try { + if (editingItem) { + 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 + ), + roadmap_votes ( + id + )` + ) + .single(); + + if (error) throw error; + + setBoardItems((prev) => + prev.map((item) => (item.id === editingItem.id ? data : item)) + ); + } else { + if (!selectedColumnId) return; + + 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 + ), + roadmap_votes ( + id + )` + ) + .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({}); + }; + + return { + showItemModal, + selectedColumnId, + editingItem, + itemForm, + setItemForm, + isSubmitting, + formErrors, + handleAddItem, + handleEditItem, + handleDeleteItem, + handleSubmitItem, + closeModal, + }; +} diff --git a/apps/web/components/roadmap/types.ts b/apps/web/components/roadmap/types.ts new file mode 100644 index 0000000..9f94dcb --- /dev/null +++ b/apps/web/components/roadmap/types.ts @@ -0,0 +1,29 @@ +import { + IRoadmapCategory, + IRoadmapItem, + IRoadmapVote, +} from "@changes-page/supabase/types/page"; + +export interface RoadmapItemWithRelations extends IRoadmapItem { + roadmap_categories?: Pick; + roadmap_votes?: Pick[]; +} + +export interface ItemsByColumn { + [columnId: string]: RoadmapItemWithRelations[]; +} + +export interface ItemForm { + title: string; + description: string; + category_id: string; +} + +export interface DragOverPosition { + itemId: string; + position: "before" | "after"; +} + +export interface FormErrors { + [key: string]: string; +} 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)); + + const { data: board, error: boardError } = await supabase + .from("roadmap_boards") + .select( + ` + *, + roadmap_columns ( + * + ), + roadmap_items ( + *, + roadmap_categories ( + id, + name, + color + ), + roadmap_votes ( + id + ) + ), + roadmap_categories ( + * + ) + ` + ) + .eq("id", board_id) + .eq("page_id", page_id) + .single(); + + if (boardError || !board) { + console.error("Failed to fetch roadmap board data", boardError); + return { + notFound: true, + }; + } + + const columns = (board.roadmap_columns || []).sort( + (a, b) => (a.position || 0) - (b.position || 0) + ); + const items = (board.roadmap_items || []).sort( + (a, b) => (a.position || 0) - (b.position || 0) + ); + const categories = (board.roadmap_categories || []).sort( + (a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + + return { + props: { + page_id, + page, + settings, + board, + columns: columns || [], + items: items || [], + categories: categories || [], + }, + }; +} + +export default function RoadmapBoardDetails({ + page_id, + board, + columns, + items, + categories, +}: InferGetServerSidePropsType) { + const router = useRouter(); + + if (!page_id || !board) return null; + + return ( + <> + + router.push(`/pages/${page_id}/roadmap/${board.id}/settings`) + } + /> + } + > + + + + ); +} + +RoadmapBoardDetails.getLayout = function getLayout(page: JSX.Element) { + return {page}; +}; diff --git a/apps/web/pages/pages/[page_id]/roadmap/[board_id]/settings.tsx b/apps/web/pages/pages/[page_id]/roadmap/[board_id]/settings.tsx new file mode 100644 index 0000000..0de6d04 --- /dev/null +++ b/apps/web/pages/pages/[page_id]/roadmap/[board_id]/settings.tsx @@ -0,0 +1,890 @@ +import { + getCategoryColorClasses, + getCategoryColorOptions, + ROADMAP_COLORS, +} from "@changes-page/utils"; +import { MenuIcon } from "@heroicons/react/outline"; +import { PencilIcon, PlusIcon, TrashIcon } from "@heroicons/react/solid"; +import { InferGetServerSidePropsType } from "next"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState, type JSX } from "react"; +import AuthLayout from "../../../../../components/layout/auth-layout.component"; +import Page from "../../../../../components/layout/page.component"; +import usePageSettings from "../../../../../utils/hooks/usePageSettings"; +import { getPageUrl } from "../../../../../utils/hooks/usePageUrl"; +import { getSupabaseServerClient } from "../../../../../utils/supabase/supabase-admin"; +import { createOrRetrievePageSettings } from "../../../../../utils/useDatabase"; +import { getPage } from "../../../../../utils/useSSR"; +import { useUserData } from "../../../../../utils/useUser"; + +export async function getServerSideProps({ req, res, params, query }) { + const { page_id, board_id } = params; + const { tab = "board" } = query; + + const { supabase } = await getSupabaseServerClient({ req, res }); + const page = await getPage(supabase, page_id).catch((e) => { + 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 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 || [], + categories: categories || [], + initialTab: tab, + }, + }; +} + +export default function BoardSettings({ + page, + page_id, + settings: serverSettings, + board, + columns, + categories, + initialTab, +}: InferGetServerSidePropsType) { + const router = useRouter(); + const { supabase, 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] + ); + + // Active tab state + const [activeTab, setActiveTab] = useState(initialTab || "board"); + + // Update activeTab when URL changes + useEffect(() => { + const { tab } = router.query; + if (tab && typeof tab === "string") { + setActiveTab(tab); + } + }, [router.query]); + + // Board form state + const [boardForm, setBoardForm] = useState({ + title: board.title, + description: board.description || "", + slug: board.slug, + is_public: board.is_public, + }); + const [isSavingBoard, setIsSavingBoard] = useState(false); + const [slugError, setSlugError] = useState(""); + + // Categories state + const [boardCategories, setBoardCategories] = useState(categories); + const [newCategory, setNewCategory] = useState(""); + const [newCategoryColor, setNewCategoryColor] = useState("blue"); + const [editingCategory, setEditingCategory] = useState(null); + const [categoryToEdit, setCategoryToEdit] = useState(""); + const [categoryColorToEdit, setCategoryColorToEdit] = useState("blue"); + + // Columns state + const [boardColumns, setBoardColumns] = useState(columns); + const [newColumn, setNewColumn] = useState(""); + const [editingColumn, setEditingColumn] = useState(null); + const [columnToEdit, setColumnToEdit] = useState(""); + + // Drag and drop state for columns + const [draggedColumn, setDraggedColumn] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const tabs = [ + { + name: "Settings", + current: activeTab === "board", + href: `/pages/${page_id}/roadmap/${board.id}/settings?tab=board`, + }, + { + name: "Stages", + current: activeTab === "columns", + href: `/pages/${page_id}/roadmap/${board.id}/settings?tab=columns`, + }, + { + name: "Categories", + current: activeTab === "categories", + href: `/pages/${page_id}/roadmap/${board.id}/settings?tab=categories`, + }, + ]; + + // Slug validation function + const validateSlug = (slug: string) => { + if (!slug.trim()) { + return "Slug is required"; + } + if (!/^[a-z0-9-]+$/.test(slug)) { + return "Slug can only contain lowercase letters, numbers, and hyphens"; + } + if (slug.length < 3) { + return "Slug must be at least 3 characters long"; + } + if (slug.length > 50) { + return "Slug must be less than 50 characters"; + } + return ""; + }; + + // Auto-generate slug from title + const generateSlugFromTitle = (title: string) => { + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + }; + + // Handle slug input changes + const handleSlugChange = (newSlug: string) => { + setBoardForm((prev) => ({ ...prev, slug: newSlug })); + const error = validateSlug(newSlug); + setSlugError(error); + }; + + // Board settings functions + const handleUpdateBoard = async (e) => { + e.preventDefault(); + if (!isPageOwner) return; + + // Validate slug + const slugValidationError = validateSlug(boardForm.slug); + if (slugValidationError) { + setSlugError(slugValidationError); + return; + } + + setIsSavingBoard(true); + try { + const { error } = await supabase + .from("roadmap_boards") + .update({ + title: boardForm.title.trim(), + description: boardForm.description.trim() || null, + slug: boardForm.slug.trim(), + is_public: boardForm.is_public, + }) + .eq("id", board.id); + + if (error) { + if (error.code === "23505") { + setSlugError("This slug is already in use"); + return; + } + throw error; + } + + // Refresh the page to show updated settings + window.location.reload(); + } catch (error) { + console.error("Error updating board:", error); + alert("Failed to update board settings"); + } finally { + setIsSavingBoard(false); + } + }; + + // Category functions + const handleAddCategory = async (e) => { + e.preventDefault(); + if (!newCategory.trim() || !isPageOwner) return; + + try { + const { data, error } = await supabase + .from("roadmap_categories") + .insert({ + board_id: board.id, + name: newCategory.trim(), + color: newCategoryColor, + }) + .select() + .single(); + + if (error) throw error; + + setBoardCategories([...boardCategories, data]); + setNewCategory(""); + setNewCategoryColor("blue"); + } catch (error) { + console.error("Error adding category:", error); + alert("Failed to add category"); + } + }; + + const handleUpdateCategory = async (categoryId) => { + if (!categoryToEdit.trim() || !isPageOwner) return; + + try { + const { error } = await supabase + .from("roadmap_categories") + .update({ + name: categoryToEdit.trim(), + color: categoryColorToEdit, + }) + .eq("id", categoryId); + + if (error) throw error; + + setBoardCategories((prev) => + prev.map((cat) => + cat.id === categoryId + ? { + ...cat, + name: categoryToEdit.trim(), + color: categoryColorToEdit, + } + : cat + ) + ); + setEditingCategory(null); + setCategoryToEdit(""); + setCategoryColorToEdit("blue"); + } catch (error) { + console.error("Error updating category:", error); + alert("Failed to update category"); + } + }; + + const handleDeleteCategory = async (categoryId) => { + // Check if category has items + try { + const { data: itemsWithCategory, error: checkError } = await supabase + .from("roadmap_items") + .select("id") + .eq("category_id", categoryId); + + if (checkError) throw checkError; + + const itemCount = itemsWithCategory?.length || 0; + let confirmMessage = "Are you sure you want to delete this category?"; + + if (itemCount > 0) { + confirmMessage = `This category is used by ${itemCount} item(s). Items will become uncategorized. Are you sure?`; + } + + if (!confirm(confirmMessage)) return; + + const { error } = await supabase + .from("roadmap_categories") + .delete() + .eq("id", categoryId); + + if (error) throw error; + + setBoardCategories((prev) => prev.filter((cat) => cat.id !== categoryId)); + } catch (error) { + console.error("Error deleting category:", error); + alert("Failed to delete category"); + } + }; + + // Column functions + const handleAddColumn = async (e) => { + e.preventDefault(); + if (!newColumn.trim() || !isPageOwner) return; + + try { + const maxPosition = + Math.max(...boardColumns.map((col) => col.position)) + 1; + + const { data, error } = await supabase + .from("roadmap_columns") + .insert({ + board_id: board.id, + name: newColumn.trim(), + position: maxPosition, + }) + .select() + .single(); + + if (error) throw error; + + setBoardColumns([...boardColumns, data]); + setNewColumn(""); + } catch (error) { + console.error("Error adding column:", error); + alert("Failed to add column"); + } + }; + + const handleUpdateColumn = async (columnId) => { + if (!columnToEdit.trim() || !isPageOwner) return; + + try { + const { error } = await supabase + .from("roadmap_columns") + .update({ name: columnToEdit.trim() }) + .eq("id", columnId); + + if (error) throw error; + + setBoardColumns((prev) => + prev.map((col) => + col.id === columnId ? { ...col, name: columnToEdit.trim() } : col + ) + ); + setEditingColumn(null); + setColumnToEdit(""); + } catch (error) { + console.error("Error updating column:", error); + alert("Failed to update column"); + } + }; + + const handleDeleteColumn = async (columnId) => { + // Check if stage has items + try { + const { data: itemsInColumn, error: checkError } = await supabase + .from("roadmap_items") + .select("id") + .eq("column_id", columnId); + + if (checkError) throw checkError; + + const itemCount = itemsInColumn?.length || 0; + + if (itemCount > 0) { + alert( + `Cannot delete this stage because it contains ${itemCount} item(s).\n\nTo delete this stage, first move or delete all items from it.` + ); + return; + } else { + if (!confirm("Are you sure you want to delete this stage?")) return; + } + + const { error } = await supabase + .from("roadmap_columns") + .delete() + .eq("id", columnId); + + if (error) throw error; + + setBoardColumns((prev) => prev.filter((col) => col.id !== columnId)); + } catch (error) { + console.error("Error deleting column:", error); + alert("Failed to delete column"); + } + }; + + // Drag and drop handlers for columns + const handleColumnDragStart = (e, column, index) => { + setDraggedColumn({ column, index }); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleColumnDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + }; + + const handleColumnDragLeave = (e) => { + // Only clear drag over if leaving the entire container + if (!e.currentTarget.contains(e.relatedTarget)) { + setDragOverIndex(null); + } + }; + + const handleColumnDrop = async (e, dropIndex) => { + e.preventDefault(); + setDragOverIndex(null); + + if (!draggedColumn || draggedColumn.index === dropIndex) { + setDraggedColumn(null); + return; + } + + try { + // Create new array with reordered columns + const newColumns = [...boardColumns]; + const [movedColumn] = newColumns.splice(draggedColumn.index, 1); + newColumns.splice(dropIndex, 0, movedColumn); + + // Update positions in database + const updatePromises = newColumns.map((column, index) => + supabase + .from("roadmap_columns") + .update({ position: index + 1 }) + .eq("id", column.id) + ); + + await Promise.all(updatePromises); + + // Update local state + setBoardColumns( + newColumns.map((column, index) => ({ + ...column, + position: index + 1, + })) + ); + } catch (error) { + console.error("Error reordering stages:", error); + alert("Failed to reorder stages"); + } + + setDraggedColumn(null); + }; + + const handleColumnDragEnd = () => { + setDraggedColumn(null); + setDragOverIndex(null); + }; + + if (!page_id || !board || !isPageOwner) { + return
Access denied
; + } + + return ( + +
+ {/* Board Settings Tab */} + {activeTab === "board" && ( +
+
+
+ + + setBoardForm((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" + required + /> +
+ +
+ +