Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions app/(authenticated)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export default function DashboardPage() {
errors: string[];
warnings: string[];
} | null>(null);
const [categoryOptions, setCategoryOptions] = useState<Array<{ id: number; name: string }>>([]);
const [categoryModal, setCategoryModal] = useState<{ toolId: string; toolName: string } | null>(null);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
const [savingCategories, setSavingCategories] = useState(false);
const [categoryError, setCategoryError] = useState<string | null>(null);

useEffect(() => {
// Get auth token from sessionStorage (set by layout)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -673,6 +747,27 @@ export default function DashboardPage() {
}
}}
>
{(!tool.categories || tool.categories.length === 0) && (
<button
role="menuitem"
onClick={() => {
setOpenMoreMenuForToolId(null);
moreMenuAnchorRef.current = null;
openCategoryModal(tool.id, tool.name);
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-700 hover:bg-blue-50"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
Assign categories
</button>
)}
<button
role="menuitem"
onClick={() => {
Expand Down Expand Up @@ -850,6 +945,92 @@ export default function DashboardPage() {
</div>
</div>
)}

{/* Assign Categories Modal */}
{categoryModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="category-modal-title"
tabIndex={-1}
autoFocus
onKeyDown={(e) => e.key === "Escape" && !savingCategories && setCategoryModal(null)}
>
<div className="absolute inset-0 bg-black/50" onClick={() => !savingCategories && setCategoryModal(null)} />
<div className="relative w-full max-w-lg rounded-xl bg-white shadow-2xl">
<div className="flex items-start justify-between border-b border-slate-200 px-6 py-4">
<div>
<h2 id="category-modal-title" className="text-lg font-semibold text-slate-900">
Assign categories
</h2>
<p className="text-sm text-slate-500">
<span className="font-medium text-slate-700">{categoryModal.toolName}</span>
</p>
</div>
<button
onClick={() => !savingCategories && setCategoryModal(null)}
className="ml-4 rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-96 overflow-y-auto px-6 py-4">
{categoryError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-800">{categoryError}</div>
)}
{categoryOptions.length === 0 ? (
<p className="text-sm text-red-600">No categories available. Please contact an administrator.</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{categoryOptions.map((category) => {
const isSelected = selectedCategoryIds.includes(category.id);
const isDisabled = savingCategories || (selectedCategoryIds.length >= 3 && !isSelected);
return (
<label
key={category.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all ${
isSelected ? "bg-blue-50 border-blue-500 text-blue-900" : "bg-white border-slate-300 text-slate-700 hover:border-blue-300"
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleCategoryToggle(category.id)}
disabled={isDisabled}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium">{category.name}</span>
</label>
);
})}
</div>
<p className="mt-2 text-xs text-slate-500">Select up to 3 categories that best describe your tool ({selectedCategoryIds.length}/3 selected)</p>
</>
)}
</div>
<div className="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
<button
onClick={() => setCategoryModal(null)}
disabled={savingCategories}
className="btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleAssignCategories}
disabled={savingCategories || selectedCategoryIds.length === 0 || categoryOptions.length === 0}
className="btn-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{savingCategories ? "Saving..." : "Save categories"}
</button>
</div>
</div>
</div>
)}
</main>
);
}
140 changes: 140 additions & 0 deletions app/api/tools/update-categories/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
2 changes: 1 addition & 1 deletion lib/mock-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
Loading