From 601f9dd1de5602e8ef436cd2ec49f986a9b4fbab Mon Sep 17 00:00:00 2001 From: Lucas Hahne Date: Tue, 18 Aug 2026 19:27:45 +0200 Subject: [PATCH] Added Category update option to more options inside dashboard --- app/(authenticated)/dashboard/page.tsx | 181 +++++++++++++++++++++++ app/api/tools/update-categories/route.ts | 140 ++++++++++++++++++ lib/mock-tools.ts | 2 +- next-env.d.ts | 2 +- 4 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 app/api/tools/update-categories/route.ts diff --git a/app/(authenticated)/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx index 0d7d025..65dd5a6 100644 --- a/app/(authenticated)/dashboard/page.tsx +++ b/app/(authenticated)/dashboard/page.tsx @@ -76,6 +76,11 @@ export default function DashboardPage() { errors: string[]; warnings: string[]; } | null>(null); + const [categoryOptions, setCategoryOptions] = useState>([]); + const [categoryModal, setCategoryModal] = useState<{ toolId: string; toolName: string } | null>(null); + const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); + const [savingCategories, setSavingCategories] = useState(false); + const [categoryError, setCategoryError] = useState(null); useEffect(() => { // Get auth token from sessionStorage (set by layout) @@ -113,6 +118,20 @@ export default function DashboardPage() { })(); }, []); + // Fetch category options once for the "Assign categories" modal + useEffect(() => { + (async () => { + try { + const response = await fetch("/api/categories"); + if (!response.ok) throw new Error("Failed to fetch categories"); + const data = await response.json(); + setCategoryOptions(Array.isArray(data) ? data : []); + } catch (error) { + console.error("Error fetching categories:", error); + } + })(); + }, []); + // Close the "More" dropdown on scroll or resize to avoid stale fixed positioning useEffect(() => { if (openMoreMenuForToolId === null) return; @@ -204,6 +223,61 @@ export default function DashboardPage() { } }; + const openCategoryModal = (toolId: string, toolName: string) => { + setCategoryModal({ toolId, toolName }); + setSelectedCategoryIds([]); + setCategoryError(null); + }; + + const handleCategoryToggle = (categoryId: number) => { + setSelectedCategoryIds((prev) => { + if (prev.includes(categoryId)) { + return prev.filter((id) => id !== categoryId); + } else if (prev.length < 3) { + return [...prev, categoryId]; + } + return prev; + }); + }; + + const handleAssignCategories = async () => { + if (!categoryModal || !authToken) return; + + if (selectedCategoryIds.length === 0) { + setCategoryError("Please select at least one category"); + return; + } + + setSavingCategories(true); + setCategoryError(null); + try { + const response = await fetch("/api/tools/update-categories", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ toolId: categoryModal.toolId, categoryIds: selectedCategoryIds }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to assign categories"); + } + + const assigned: Array<{ id: number; name: string }> = data.categories || []; + setTools((prevTools) => prevTools.map((tool) => (tool.id === categoryModal.toolId ? { ...tool, categories: assigned } : tool))); + setCategoryModal(null); + setSelectedCategoryIds([]); + } catch (error) { + console.error("Error assigning categories:", error); + setCategoryError(error instanceof Error ? error.message : "Failed to assign categories. Please try again."); + } finally { + setSavingCategories(false); + } + }; + // Filter tools based on view mode. Intakes only appear in "My Tools". const filteredTools = viewMode === "my" @@ -673,6 +747,27 @@ export default function DashboardPage() { } }} > + {(!tool.categories || tool.categories.length === 0) && ( + + )} + +
+ {categoryError && ( +
{categoryError}
+ )} + {categoryOptions.length === 0 ? ( +

No categories available. Please contact an administrator.

+ ) : ( + <> +
+ {categoryOptions.map((category) => { + const isSelected = selectedCategoryIds.includes(category.id); + const isDisabled = savingCategories || (selectedCategoryIds.length >= 3 && !isSelected); + return ( + + ); + })} +
+

Select up to 3 categories that best describe your tool ({selectedCategoryIds.length}/3 selected)

+ + )} +
+
+ + +
+ + + )} ); } diff --git a/app/api/tools/update-categories/route.ts b/app/api/tools/update-categories/route.ts new file mode 100644 index 0000000..a8d0e35 --- /dev/null +++ b/app/api/tools/update-categories/route.ts @@ -0,0 +1,140 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; + +// Create Supabase client with service role for server-side operations +function getSupabaseClient() { + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceKey) { + return null; + } + + return createClient(supabaseUrl, supabaseServiceKey); +} + +interface UpdateCategoriesRequest { + toolId: string; + categoryIds: number[]; +} + +export async function POST(request: NextRequest) { + try { + const supabase = getSupabaseClient(); + + if (!supabase) { + return NextResponse.json({ error: "Database connection not configured" }, { status: 500 }); + } + + // Verify user is authenticated + const authHeader = request.headers.get("authorization"); + let userId: string | null = null; + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(token); + + if (!authError && user) { + userId = user.id; + } else { + return NextResponse.json({ error: "Unauthorized. Valid user token required." }, { status: 401 }); + } + } + + if (!userId) { + return NextResponse.json({ error: "Unauthorized. Please sign in." }, { status: 401 }); + } + + // Parse request body + const body = (await request.json()) as UpdateCategoriesRequest; + const { toolId, categoryIds } = body; + + if (!toolId) { + return NextResponse.json({ error: "toolId is required" }, { status: 400 }); + } + + if (!categoryIds || !Array.isArray(categoryIds) || categoryIds.length === 0) { + return NextResponse.json({ error: "At least one category is required" }, { status: 400 }); + } + + const uniqueCategoryIds = Array.from(new Set(categoryIds)); + + if (uniqueCategoryIds.length > 3) { + return NextResponse.json({ error: "Please select no more than 3 categories" }, { status: 400 }); + } + + // Verify the tool exists and belongs to the user + const { data: tool, error: fetchError } = await supabase.from("tools").select("id, user_id").eq("id", toolId).single(); + + if (fetchError || !tool) { + return NextResponse.json({ error: "Tool not found" }, { status: 404 }); + } + + if (tool.user_id !== userId) { + return NextResponse.json({ error: "You do not have permission to update this tool" }, { status: 403 }); + } + + // Empty-only: refuse to change categories on a tool that already has some + const { data: existingRelations, error: existingError } = await supabase + .from("tool_categories") + .select("category_id") + .eq("tool_id", toolId); + + if (existingError) { + console.error("Error checking existing tool categories:", existingError); + return NextResponse.json({ error: "Failed to load current categories. Please try again." }, { status: 500 }); + } + + if (existingRelations && existingRelations.length > 0) { + return NextResponse.json({ error: "This tool already has categories assigned." }, { status: 409 }); + } + + // Validate that all provided category IDs exist + const { data: existingCategories, error: categoriesLookupError } = await supabase + .from("categories") + .select("id, name") + .in("id", uniqueCategoryIds); + + if (categoriesLookupError || !existingCategories) { + console.error("Error validating categories:", categoriesLookupError); + return NextResponse.json({ error: "Failed to validate categories. Please try again." }, { status: 500 }); + } + + const validCategoryIds = new Set(existingCategories.map((c) => c.id)); + const invalidCount = uniqueCategoryIds.filter((id) => !validCategoryIds.has(id)).length; + + if (invalidCount > 0) { + return NextResponse.json( + { + error: `${invalidCount} selected ${invalidCount === 1 ? "category is" : "categories are"} invalid. Please try again with valid categories.`, + }, + { status: 400 }, + ); + } + + // Insert category relationships + const categoryRelations = uniqueCategoryIds.map((categoryId) => ({ + tool_id: toolId, + category_id: categoryId, + })); + + const { error: insertError } = await supabase.from("tool_categories").insert(categoryRelations); + + if (insertError) { + console.error("Error inserting tool categories:", insertError); + return NextResponse.json({ error: "Failed to save tool categories. Please try again." }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: "Categories assigned successfully", + categories: existingCategories.map((c) => ({ id: c.id, name: c.name })), + }); + } catch (error) { + console.error("Error updating tool categories:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/lib/mock-tools.ts b/lib/mock-tools.ts index 76b7d08..a4615f3 100644 --- a/lib/mock-tools.ts +++ b/lib/mock-tools.ts @@ -21,7 +21,7 @@ export const mockTools: MockTool[] = [ description: "Manage your Power Platform solutions with ease. Export, import, and version control your solutions.", icon: "📦", contributors: ["Power Platform ToolBox"], - categories: ["Solutions"], + categories: [], downloads: 1250, rating: 4.8, mau: 320, diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.