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?.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 (
<>
-
)}
- {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 */}
+
+
+
+ >
+ );
+}
+
+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 (
+
+
+
+ );
+}
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" && (
+
+ )}
+
+ {/* Categories Tab */}
+ {activeTab === "categories" && (
+
+ {/* Existing Categories List */}
+
+
+ {boardCategories.map((category) => (
+
+ {editingCategory === category.id ? (
+
+
+ setCategoryToEdit(e.target.value)}
+ className="flex-1 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"
+ autoFocus
+ />
+
+
+
+
+ {getCategoryColorOptions().map((color) => (
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+
+
+ {category.name}
+
+
+
+
+
+
+ >
+ )}
+
+ ))}
+
+
+
+ {/* Add New Category Form */}
+
+
+ Add New Category
+
+
+
+
+
+ )}
+
+ {/* Stages Tab */}
+ {activeTab === "columns" && (
+
+ {/* Existing Stages List */}
+
+
+ {boardColumns.map((column, index) => (
+
handleColumnDragStart(e, column, index)}
+ onDragOver={(e) => handleColumnDragOver(e, index)}
+ onDragLeave={handleColumnDragLeave}
+ onDrop={(e) => handleColumnDrop(e, index)}
+ onDragEnd={handleColumnDragEnd}
+ className={`flex items-center justify-between p-3 border rounded-lg transition-colors ${
+ dragOverIndex === index
+ ? "border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20"
+ : "border-gray-200 dark:border-gray-700"
+ } ${draggedColumn?.index === index ? "opacity-50" : ""} ${
+ editingColumn === column.id ? "" : "cursor-move"
+ }`}
+ >
+ {editingColumn === column.id ? (
+
+ setColumnToEdit(e.target.value)}
+ className="flex-1 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"
+ autoFocus
+ />
+
+
+
+ ) : (
+ <>
+
+
+
+
+ #{index + 1}
+
+
+
+ {column.name}
+
+
+
+
+
+
+ >
+ )}
+
+ ))}
+
+
+
+ {/* Add New Stage Form */}
+
+
+ Add New Stage
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+BoardSettings.getLayout = function getLayout(page: JSX.Element) {
+ return {page};
+};
diff --git a/apps/web/pages/pages/[page_id]/roadmap/index.tsx b/apps/web/pages/pages/[page_id]/roadmap/index.tsx
new file mode 100644
index 0000000..1855749
--- /dev/null
+++ b/apps/web/pages/pages/[page_id]/roadmap/index.tsx
@@ -0,0 +1,171 @@
+import { PlusIcon } from "@heroicons/react/solid";
+import { InferGetServerSidePropsType } from "next";
+import { useMemo, type JSX } from "react";
+import Link from "next/link";
+import { PrimaryRouterButton } from "../../../../components/core/buttons.component";
+import AuthLayout from "../../../../components/layout/auth-layout.component";
+import Page from "../../../../components/layout/page.component";
+import { ROUTES } from "../../../../data/routes.data";
+import { getSupabaseServerClient } from "../../../../utils/supabase/supabase-admin";
+import { getPage } from "../../../../utils/useSSR";
+
+export async function getServerSideProps({ req, res, params }) {
+ const { page_id } = params;
+
+ 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 { data: boards, error: boardsError } = await supabase
+ .from("roadmap_boards")
+ .select("*")
+ .eq("page_id", page_id)
+ .order("created_at", { ascending: false });
+
+ if (boardsError) {
+ console.error("Failed to fetch roadmap boards", boardsError);
+ }
+
+ return {
+ props: {
+ page_id,
+ page,
+ boards: boards || [],
+ },
+ };
+}
+
+export default function RoadmapPage({
+ page,
+ page_id,
+ boards,
+}: InferGetServerSidePropsType) {
+ const viewTabs = useMemo(
+ () => [
+ {
+ name: "Changelog",
+ current: false,
+ href: `/pages/${page_id}`,
+ },
+ {
+ name: "Roadmap",
+ current: true,
+ href: `/pages/${page_id}/roadmap`,
+ },
+ ],
+ [page_id]
+ );
+
+ if (!page_id) return null;
+
+ return (
+ }
+ route={`/pages/${page_id}/roadmap/new`}
+ />
+ }
+ >
+
+ {boards.length === 0 ? (
+
+
+
+ No roadmap boards
+
+
+ Get started by creating your first roadmap board.
+
+
+ ) : (
+
+ {boards.map((board) => (
+
+
+
+
+
+
+ {board.title}
+
+
+ {board.is_public ? "Public" : "Private"}
+
+
+
+ {board.description && (
+
+ {board.description}
+
+ )}
+
+
+
+
+
Created
+
+ {new Date(board.created_at).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+RoadmapPage.getLayout = function getLayout(page: JSX.Element) {
+ return {page};
+};
diff --git a/apps/web/pages/pages/[page_id]/roadmap/new.tsx b/apps/web/pages/pages/[page_id]/roadmap/new.tsx
new file mode 100644
index 0000000..f9a5e79
--- /dev/null
+++ b/apps/web/pages/pages/[page_id]/roadmap/new.tsx
@@ -0,0 +1,279 @@
+import { InferGetServerSidePropsType } from "next";
+import { useRouter } from "next/router";
+import { 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 { 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 }) {
+ const { page_id } = params;
+
+ 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));
+
+ return {
+ props: {
+ page_id,
+ page,
+ settings,
+ },
+ };
+}
+
+export default function NewRoadmapBoard({
+ page,
+ page_id,
+ settings: serverSettings,
+}: InferGetServerSidePropsType) {
+ const router = useRouter();
+ const { supabase, user } = useUserData();
+ const { settings: clientSettings } = usePageSettings(page_id, false);
+
+ const settings = useMemo(
+ () => clientSettings ?? serverSettings,
+ [serverSettings, clientSettings]
+ );
+
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ slug: "",
+ });
+ const [isCreating, setIsCreating] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ const handleInputChange = (field: string, value: string) => {
+ if (field === "title") {
+ setFormData((prev) => {
+ const newFormData = { ...prev, [field]: value };
+
+ // Auto-generate slug from title if slug field is empty or hasn't been manually edited
+ if (!prev.slug || prev.slug === generateSlugFromTitle(prev.title)) {
+ const slug = generateSlugFromTitle(value);
+ newFormData.slug = slug;
+ }
+
+ return newFormData;
+ });
+ } else {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ }
+
+ // Clear errors
+ if (errors[field]) {
+ setErrors((prev) => ({ ...prev, [field]: "" }));
+ }
+ };
+
+ const generateSlugFromTitle = (title: string) => {
+ return title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ };
+
+ const validateForm = () => {
+ const newErrors: Record = {};
+
+ if (!formData.title.trim()) {
+ newErrors.title = "Title is required";
+ }
+
+ if (!formData.slug.trim()) {
+ newErrors.slug = "Slug is required";
+ } else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
+ newErrors.slug =
+ "Slug can only contain lowercase letters, numbers, and hyphens";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) return;
+
+ setIsCreating(true);
+
+ try {
+ // Create the roadmap board
+ const { data: board, error: boardError } = await supabase
+ .from("roadmap_boards")
+ .insert({
+ page_id,
+ title: formData.title.trim(),
+ description: formData.description.trim() || null,
+ slug: formData.slug.trim(),
+ is_public: true,
+ })
+ .select()
+ .single();
+
+ if (boardError) {
+ if (boardError.code === "23505") {
+ // Unique constraint violation
+ setErrors({ slug: "This slug is already in use" });
+ } else {
+ console.error("Error creating board:", boardError);
+ setErrors({ general: "Failed to create roadmap board" });
+ }
+ return;
+ }
+
+ // Initialize default stages for the board
+ await supabase.rpc("initialize_roadmap_columns", { board_id: board.id });
+
+ // Initialize default categories for the board
+ await supabase.rpc("initialize_roadmap_categories", {
+ board_id: board.id,
+ });
+
+ // Redirect to the roadmap page
+ await router.push(`/pages/${page_id}/roadmap`);
+ } catch (error) {
+ console.error("Error creating roadmap board:", error);
+ setErrors({ general: "Failed to create roadmap board" });
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ if (!page_id) return null;
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+NewRoadmapBoard.getLayout = function getLayout(page: JSX.Element) {
+ return {page};
+};
diff --git a/apps/web/pages/pages/index.tsx b/apps/web/pages/pages/index.tsx
index ba2aba4..286274c 100644
--- a/apps/web/pages/pages/index.tsx
+++ b/apps/web/pages/pages/index.tsx
@@ -2,6 +2,7 @@ import { PageType, PageTypeToLabel } from "@changes-page/supabase/types/page";
import { PlusIcon, UserGroupIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
+import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, type JSX } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -107,79 +108,89 @@ export default function Pages({
{pages.length ? (
-
- {pages.map((page, index) => (
-
+ {pages.map((page) => (
+
-
-
- {PageTypeToLabel[page.type]}
-
-
-
-
-
- {page.description}
-
-
- {page.page_settings?.custom_domain
- ? page.page_settings.custom_domain
- : `${page.url_slug}.changes.page`
- }
-
-
-
- {page.teams && page.user_id !== user?.id ? (
-
-
-
- Editor ({page.teams.name})
-
+
+
+
+
+ {PageTypeToLabel[page.type]}
+
+ {page.teams && page.user_id !== user?.id && (
+
+
+
+ Editor ({page.teams.name})
+
+
+ )}
+
+
+
+
+
+ {page.title}
+
+
+ {page.description && (
+
+ {page.description}
+
+ )}
+
+ {page.page_settings?.custom_domain
+ ? page.page_settings.custom_domain
+ : `${page.url_slug}.changes.page`}
+
+
- ) : null}
-
-
+
+
+
))}
-
+
) : null}
diff --git a/apps/web/styles/global.css b/apps/web/styles/global.css
index 0c6d26f..68b588e 100644
--- a/apps/web/styles/global.css
+++ b/apps/web/styles/global.css
@@ -90,6 +90,7 @@ body {
#nprogress .bar {
background: #4f46e5 !important;
+ height: 4px !important;
}
#nprogress .peg {
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index bfb8ab4..ef0a938 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -6,6 +6,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: {
colors: {
diff --git a/package.json b/package.json
index f45c711..1062b37 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"dev:page": "pnpm --filter './apps/page' -r dev",
"dev:web": "pnpm --filter './apps/web' -r dev",
"dev:docs": "pnpm --filter './apps/docs' -r dev",
+ "dev:apps": "pnpm --filter './apps/{page,web}' -r dev",
"dev": "pnpm -r dev",
"test": "echo \"Error: no test specified\" && exit 1"
},
diff --git a/packages/supabase/migrations/18_roadmap.sql b/packages/supabase/migrations/18_roadmap.sql
new file mode 100644
index 0000000..d87d722
--- /dev/null
+++ b/packages/supabase/migrations/18_roadmap.sql
@@ -0,0 +1,163 @@
+-- ROADMAP BOARDS table
+create table roadmap_boards (
+ id uuid default uuid_generate_v4() primary key,
+ page_id uuid references pages(id) on delete cascade not null,
+ title text not null,
+ description text,
+ slug text not null,
+ is_public boolean not null default true,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table roadmap_boards add constraint unique_page_slug unique (page_id, slug);
+alter table roadmap_boards add constraint slug_length check (length(slug) >= 1);
+
+alter table roadmap_boards enable row level security;
+create policy "Can view public roadmap boards." on roadmap_boards for select using (is_public = true);
+create policy "Can view own page roadmap boards." on roadmap_boards for select using (page_id in (select id from pages));
+create policy "Can insert own page roadmap boards." on roadmap_boards for insert with check (page_id in (select id from pages));
+create policy "Can update own page roadmap boards." on roadmap_boards for update using (page_id in (select id from pages));
+create policy "Can delete own page roadmap boards." on roadmap_boards for delete using (page_id in (select id from pages));
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON roadmap_boards
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+-- ROADMAP COLUMNS table
+create table roadmap_columns (
+ id uuid default uuid_generate_v4() primary key,
+ board_id uuid references roadmap_boards(id) on delete cascade not null,
+ name text not null,
+ position integer not null,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table roadmap_columns add constraint unique_board_position unique (board_id, position);
+alter table roadmap_columns add constraint unique_board_name unique (board_id, name);
+
+alter table roadmap_columns enable row level security;
+create policy "Can view public roadmap columns." on roadmap_columns for select using (board_id in (select id from roadmap_boards where is_public = true));
+create policy "Can view own roadmap columns." on roadmap_columns for select using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can insert own roadmap columns." on roadmap_columns for insert with check (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can update own roadmap columns." on roadmap_columns for update using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can delete own roadmap columns." on roadmap_columns for delete using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+
+-- ROADMAP CATEGORIES table
+create table roadmap_categories (
+ id uuid default uuid_generate_v4() primary key,
+ board_id uuid references roadmap_boards(id) on delete cascade not null,
+ name text not null,
+ color text default 'blue' check (color in (
+ 'blue', 'indigo', 'purple', 'pink', 'red',
+ 'orange', 'yellow', 'green', 'emerald', 'cyan'
+ )),
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table roadmap_categories add constraint unique_board_category_name unique (board_id, name);
+alter table roadmap_categories add constraint category_name_length check (length(name) >= 1);
+
+alter table roadmap_categories enable row level security;
+create policy "Can view public roadmap categories." on roadmap_categories for select using (board_id in (select id from roadmap_boards where is_public = true));
+create policy "Can view own roadmap categories." on roadmap_categories for select using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can insert own roadmap categories." on roadmap_categories for insert with check (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can update own roadmap categories." on roadmap_categories for update using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can delete own roadmap categories." on roadmap_categories for delete using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON roadmap_categories
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+-- ROADMAP ITEMS table
+create table roadmap_items (
+ id uuid default uuid_generate_v4() primary key,
+ board_id uuid references roadmap_boards(id) on delete cascade not null,
+ column_id uuid references roadmap_columns(id) on delete cascade not null,
+ category_id uuid references roadmap_categories(id) on delete set null,
+ title text not null,
+ description text,
+ position integer not null,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null,
+ updated_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table roadmap_items add constraint unique_column_position unique (column_id, position);
+
+alter table roadmap_items enable row level security;
+create policy "Can view public roadmap items." on roadmap_items for select using (board_id in (select id from roadmap_boards where is_public = true));
+create policy "Can view own roadmap items." on roadmap_items for select using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can insert own roadmap items." on roadmap_items for insert with check (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can update own roadmap items." on roadmap_items for update using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+create policy "Can delete own roadmap items." on roadmap_items for delete using (board_id in (select id from roadmap_boards where page_id in (select id from pages)));
+
+CREATE TRIGGER set_timestamp
+BEFORE UPDATE ON roadmap_items
+FOR EACH ROW
+EXECUTE PROCEDURE trigger_set_timestamp();
+
+-- ROADMAP VOTES table (similar to post_reactions)
+create table roadmap_votes (
+ id uuid default uuid_generate_v4() primary key,
+ item_id uuid references roadmap_items(id) on delete cascade not null,
+ visitor_id uuid not null,
+ created_at timestamp with time zone default timezone('utc'::text, now()) not null
+);
+
+alter table roadmap_votes add constraint unique_item_visitor unique (item_id, visitor_id);
+
+alter table roadmap_votes enable row level security;
+
+-- Function to get vote count for roadmap items
+CREATE OR REPLACE FUNCTION roadmap_item_votes_count(itemid uuid)
+RETURNS bigint
+AS $$
+BEGIN
+ RETURN (
+ SELECT COUNT(*)
+ FROM roadmap_votes
+ WHERE item_id = itemid
+ );
+END;
+$$ LANGUAGE 'plpgsql';
+
+-- Function to check if a visitor has voted for an item
+CREATE OR REPLACE FUNCTION roadmap_item_has_voted(itemid uuid, visitorid uuid)
+RETURNS boolean
+AS $$
+BEGIN
+ RETURN EXISTS (
+ SELECT 1
+ FROM roadmap_votes
+ WHERE item_id = itemid AND visitor_id = visitorid
+ );
+END;
+$$ LANGUAGE 'plpgsql';
+
+-- Function to initialize default columns for a roadmap board
+CREATE OR REPLACE FUNCTION initialize_roadmap_columns(board_id uuid)
+RETURNS void
+AS $$
+BEGIN
+ INSERT INTO roadmap_columns (board_id, name, position) VALUES
+ (board_id, 'Backlog', 1),
+ (board_id, 'Planned', 2),
+ (board_id, 'In Progress', 3),
+ (board_id, 'Done', 4);
+END;
+$$ LANGUAGE 'plpgsql';
+
+-- Function to initialize default categories for a roadmap board
+CREATE OR REPLACE FUNCTION initialize_roadmap_categories(board_id uuid)
+RETURNS void
+AS $$
+BEGIN
+ INSERT INTO roadmap_categories (board_id, name) VALUES
+ (board_id, 'Feature'),
+ (board_id, 'Bug Fix'),
+ (board_id, 'Improvement');
+END;
+$$ LANGUAGE 'plpgsql';
diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts
index 4034a99..1072451 100644
--- a/packages/supabase/types/index.ts
+++ b/packages/supabase/types/index.ts
@@ -7,7 +7,7 @@ export type Json =
| Json[]
export type Database = {
- // Allows to automatically instanciate createClient with right options
+ // Allows to automatically instantiate createClient with right options
// instead of createClient(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "10.2.0 (e07807d)"
@@ -392,6 +392,201 @@ export type Database = {
},
]
}
+ roadmap_boards: {
+ Row: {
+ created_at: string
+ description: string | null
+ id: string
+ is_public: boolean
+ page_id: string
+ slug: string
+ title: string
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ description?: string | null
+ id?: string
+ is_public?: boolean
+ page_id: string
+ slug: string
+ title: string
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ description?: string | null
+ id?: string
+ is_public?: boolean
+ page_id?: string
+ slug?: string
+ title?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "roadmap_boards_page_id_fkey"
+ columns: ["page_id"]
+ isOneToOne: false
+ referencedRelation: "pages"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ roadmap_categories: {
+ Row: {
+ board_id: string
+ color: string | null
+ created_at: string
+ id: string
+ name: string
+ updated_at: string
+ }
+ Insert: {
+ board_id: string
+ color?: string | null
+ created_at?: string
+ id?: string
+ name: string
+ updated_at?: string
+ }
+ Update: {
+ board_id?: string
+ color?: string | null
+ created_at?: string
+ id?: string
+ name?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "roadmap_categories_board_id_fkey"
+ columns: ["board_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_boards"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ roadmap_columns: {
+ Row: {
+ board_id: string
+ created_at: string
+ id: string
+ name: string
+ position: number
+ }
+ Insert: {
+ board_id: string
+ created_at?: string
+ id?: string
+ name: string
+ position: number
+ }
+ Update: {
+ board_id?: string
+ created_at?: string
+ id?: string
+ name?: string
+ position?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "roadmap_columns_board_id_fkey"
+ columns: ["board_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_boards"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ roadmap_items: {
+ Row: {
+ board_id: string
+ category_id: string | null
+ column_id: string
+ created_at: string
+ description: string | null
+ id: string
+ position: number
+ title: string
+ updated_at: string
+ }
+ Insert: {
+ board_id: string
+ category_id?: string | null
+ column_id: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ position: number
+ title: string
+ updated_at?: string
+ }
+ Update: {
+ board_id?: string
+ category_id?: string | null
+ column_id?: string
+ created_at?: string
+ description?: string | null
+ id?: string
+ position?: number
+ title?: string
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "roadmap_items_board_id_fkey"
+ columns: ["board_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_boards"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "roadmap_items_category_id_fkey"
+ columns: ["category_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_categories"
+ referencedColumns: ["id"]
+ },
+ {
+ foreignKeyName: "roadmap_items_column_id_fkey"
+ columns: ["column_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_columns"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ roadmap_votes: {
+ Row: {
+ created_at: string
+ id: string
+ item_id: string
+ visitor_id: string
+ }
+ Insert: {
+ created_at?: string
+ id?: string
+ item_id: string
+ visitor_id: string
+ }
+ Update: {
+ created_at?: string
+ id?: string
+ item_id?: string
+ visitor_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "roadmap_votes_item_id_fkey"
+ columns: ["item_id"]
+ isOneToOne: false
+ referencedRelation: "roadmap_items"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
team_invitations: {
Row: {
created_at: string
@@ -561,13 +756,21 @@ export type Database = {
get_pages_with_inactive_subscriptions: {
Args: Record
Returns: {
+ page_created_at: string
page_id: string
page_title: string
- page_created_at: string
url: string
user_id: string
}[]
}
+ initialize_roadmap_categories: {
+ Args: { board_id: string }
+ Returns: undefined
+ }
+ initialize_roadmap_columns: {
+ Args: { board_id: string }
+ Returns: undefined
+ }
is_subscription_active: {
Args: { user_id: string }
Returns: boolean
@@ -577,40 +780,48 @@ export type Database = {
Returns: boolean
}
page_view_browsers: {
- Args: { pageid: string; date: string }
+ Args: { date: string; pageid: string }
Returns: {
- data_name: string
data_count: number
+ data_name: string
}[]
}
page_view_os: {
- Args: { pageid: string; date: string }
+ Args: { date: string; pageid: string }
Returns: {
- data_name: string
data_count: number
+ data_name: string
}[]
}
page_view_referrers: {
- Args: { pageid: string; date: string }
+ Args: { date: string; pageid: string }
Returns: {
- data_name: string
data_count: number
+ data_name: string
}[]
}
page_view_stats: {
- Args: { pageid: string; date: string }
+ Args: { date: string; pageid: string }
Returns: Record
}
post_reactions_aggregate: {
Args: { postid: string }
Returns: {
- thumbs_up_count: number
- thumbs_down_count: number
+ heart_count: number
rocket_count: number
sad_count: number
- heart_count: number
+ thumbs_down_count: number
+ thumbs_up_count: number
}[]
}
+ roadmap_item_has_voted: {
+ Args: { itemid: string; visitorid: string }
+ Returns: boolean
+ }
+ roadmap_item_votes_count: {
+ Args: { itemid: string }
+ Returns: number
+ }
}
Enums: {
page_color_scheme: "auto" | "dark" | "light"
diff --git a/packages/supabase/types/page.ts b/packages/supabase/types/page.ts
index 6b68040..5294193 100644
--- a/packages/supabase/types/page.ts
+++ b/packages/supabase/types/page.ts
@@ -1,16 +1,18 @@
-import type { Database } from "./index";
+import type { Database, Tables } from "./index";
-export type IPage = Database["public"]["Tables"]["pages"]["Row"];
-export type IPageSettings =
- Database["public"]["Tables"]["page_settings"]["Row"];
-export type IPost = Database["public"]["Tables"]["posts"]["Row"];
-export type IPageEmailSubscriber =
- Database["public"]["Tables"]["page_email_subscribers"]["Row"];
-export type IPageView = Database["public"]["Tables"]["page_views"]["Row"];
-export type ITeam = Database["public"]["Tables"]["teams"]["Row"];
-export type ITeamMember = Database["public"]["Tables"]["team_members"]["Row"];
-export type ITeamInvitation =
- Database["public"]["Tables"]["team_invitations"]["Row"];
+export type IPage = Tables<"pages">;
+export type IPageSettings = Tables<"page_settings">;
+export type IPost = Tables<"posts">;
+export type IPageEmailSubscriber = Tables<"page_email_subscribers">;
+export type IPageView = Tables<"page_views">;
+export type ITeam = Tables<"teams">;
+export type ITeamMember = Tables<"team_members">;
+export type ITeamInvitation = Tables<"team_invitations">;
+export type IRoadmapBoard = Tables<"roadmap_boards">;
+export type IRoadmapColumn = Tables<"roadmap_columns">;
+export type IRoadmapCategory = Tables<"roadmap_categories">;
+export type IRoadmapItem = Tables<"roadmap_items">;
+export type IRoadmapVote = Tables<"roadmap_votes">;
export enum PageType {
changelogs = "changelogs",
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index bba468a..269e1ca 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -1,2 +1,3 @@
export * from "./datetime";
export * from "./markdown";
+export * from "./roadmap";
diff --git a/packages/utils/roadmap/index.ts b/packages/utils/roadmap/index.ts
new file mode 100644
index 0000000..82bc1ce
--- /dev/null
+++ b/packages/utils/roadmap/index.ts
@@ -0,0 +1,78 @@
+export const ROADMAP_COLORS = {
+ blue: {
+ name: 'Blue',
+ light: 'bg-blue-100 text-blue-800 border-blue-200',
+ dark: 'dark:bg-blue-900 dark:text-blue-200 dark:border-blue-800',
+ preview: 'bg-blue-500',
+ },
+ indigo: {
+ name: 'Indigo',
+ light: 'bg-indigo-100 text-indigo-800 border-indigo-200',
+ dark: 'dark:bg-indigo-900 dark:text-indigo-200 dark:border-indigo-800',
+ preview: 'bg-indigo-500',
+ },
+ purple: {
+ name: 'Purple',
+ light: 'bg-purple-100 text-purple-800 border-purple-200',
+ dark: 'dark:bg-purple-900 dark:text-purple-200 dark:border-purple-800',
+ preview: 'bg-purple-500',
+ },
+ pink: {
+ name: 'Pink',
+ light: 'bg-pink-100 text-pink-800 border-pink-200',
+ dark: 'dark:bg-pink-900 dark:text-pink-200 dark:border-pink-800',
+ preview: 'bg-pink-500',
+ },
+ red: {
+ name: 'Red',
+ light: 'bg-red-100 text-red-800 border-red-200',
+ dark: 'dark:bg-red-900 dark:text-red-200 dark:border-red-800',
+ preview: 'bg-red-500',
+ },
+ orange: {
+ name: 'Orange',
+ light: 'bg-orange-100 text-orange-800 border-orange-200',
+ dark: 'dark:bg-orange-900 dark:text-orange-200 dark:border-orange-800',
+ preview: 'bg-orange-500',
+ },
+ yellow: {
+ name: 'Yellow',
+ light: 'bg-yellow-100 text-yellow-800 border-yellow-200',
+ dark: 'dark:bg-yellow-900 dark:text-yellow-200 dark:border-yellow-800',
+ preview: 'bg-yellow-500',
+ },
+ green: {
+ name: 'Green',
+ light: 'bg-green-100 text-green-800 border-green-200',
+ dark: 'dark:bg-green-900 dark:text-green-200 dark:border-green-800',
+ preview: 'bg-green-500',
+ },
+ emerald: {
+ name: 'Emerald',
+ light: 'bg-emerald-100 text-emerald-800 border-emerald-200',
+ dark: 'dark:bg-emerald-900 dark:text-emerald-200 dark:border-emerald-800',
+ preview: 'bg-emerald-500',
+ },
+ cyan: {
+ name: 'Cyan',
+ light: 'bg-cyan-100 text-cyan-800 border-cyan-200',
+ dark: 'dark:bg-cyan-900 dark:text-cyan-200 dark:border-cyan-800',
+ preview: 'bg-cyan-500',
+ },
+} as const;
+
+export type RoadmapColor = keyof typeof ROADMAP_COLORS;
+
+export function getCategoryColorClasses(color: string = 'blue') {
+ const colorKey = color as RoadmapColor;
+ const colorConfig = ROADMAP_COLORS[colorKey] || ROADMAP_COLORS.blue;
+ return `${colorConfig.light} ${colorConfig.dark}`;
+}
+
+export function getCategoryColorOptions() {
+ return Object.entries(ROADMAP_COLORS).map(([key, value]) => ({
+ value: key,
+ label: value.name,
+ preview: value.preview,
+ }));
+}
\ No newline at end of file