From 2b31301de607ec568bd31b4230160ae284cca3b6 Mon Sep 17 00:00:00 2001 From: Schalk Neethling Date: Thu, 27 Feb 2025 21:57:02 +0200 Subject: [PATCH 1/4] Add 'Suggest a Tool' feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a new feature that allows users to suggest tools through a dialog form. The implementation includes: - A 'Suggest a Tool' button in the header - A dialog form with fields for tool details - Client-side validation - A Netlify serverless function for processing submissions - Integration with GitHub to create pull requests automatically - Tests for the new functionality 🤖 Generated with Claude Code Co-Authored-By: Claude --- netlify.toml | 14 + netlify/functions/suggest-tool.js | 228 ++++++++++++ package.json | 2 + src/App.css | 8 + src/App.jsx | 22 +- .../molecules/suggest-tool-button/index.css | 29 ++ .../molecules/suggest-tool-button/index.jsx | 15 + src/ui/molecules/suggest-tool/index.css | 174 +++++++++ src/ui/molecules/suggest-tool/index.jsx | 340 ++++++++++++++++++ tests/suggest-tool.spec.js | 69 ++++ 10 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 netlify.toml create mode 100644 netlify/functions/suggest-tool.js create mode 100644 src/ui/molecules/suggest-tool-button/index.css create mode 100644 src/ui/molecules/suggest-tool-button/index.jsx create mode 100644 src/ui/molecules/suggest-tool/index.css create mode 100644 src/ui/molecules/suggest-tool/index.jsx create mode 100644 tests/suggest-tool.spec.js diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..a4d6447 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,14 @@ +[build] + command = "npm run build" + publish = "dist" + functions = "netlify/functions" + +[dev] + command = "npm run dev" + functions = "netlify/functions" + publish = "dist" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/netlify/functions/suggest-tool.js b/netlify/functions/suggest-tool.js new file mode 100644 index 0000000..423c99b --- /dev/null +++ b/netlify/functions/suggest-tool.js @@ -0,0 +1,228 @@ +// Netlify serverless function to handle tool suggestion submissions +import { Octokit } from '@octokit/rest'; +import formidable from 'formidable'; +import fs from 'fs'; +import path from 'path'; + +// GitHub repo info +const REPO_OWNER = 'schalkneethling'; +const REPO_NAME = 'makerbench'; + +export async function handler(event, context) { + // Only allow POST requests + if (event.httpMethod !== 'POST') { + return { + statusCode: 405, + body: JSON.stringify({ message: 'Method Not Allowed' }), + }; + } + + try { + // Parse form data + const formData = await parseMultipartForm(event); + const { title, url, description, tag, repo, logo } = formData; + + // Validate required fields + if (!title || !url || !description || !tag) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Missing required fields' }), + }; + } + + // Parse tags from JSON string to array + let tags; + try { + tags = JSON.parse(tag); + } catch (e) { + // Handle case where tags might not be a valid JSON string + tags = tag.split(',').map(t => t.trim()); + } + + // Initialize GitHub API client using GitHub token from environment variables + const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + + // 1. Get current tools.json file + const { data: repoContent } = await octokit.repos.getContent({ + owner: REPO_OWNER, + repo: REPO_NAME, + path: 'public/tools.json', + ref: 'main', // Use main branch + }); + + // Decode content from base64 + const toolsJsonContent = Buffer.from(repoContent.content, 'base64').toString(); + const tools = JSON.parse(toolsJsonContent); + + // Generate a unique ID (max ID + 1) + const newId = Math.max(...tools.map(tool => tool.id)) + 1; + + // Handle logo file if present + let logoFilename = null; + if (logo) { + // Process logo file + const fileExt = path.extname(logo.originalFilename).toLowerCase(); + logoFilename = `tool-${newId}${fileExt}`; + + // Create a branch if it doesn't exist yet + try { + const branchName = `tool-suggestion-${newId}`; + const { data: mainRef } = await octokit.git.getRef({ + owner: REPO_OWNER, + repo: REPO_NAME, + ref: 'heads/main', + }); + + await octokit.git.createRef({ + owner: REPO_OWNER, + repo: REPO_NAME, + ref: `refs/heads/${branchName}`, + sha: mainRef.object.sha, + }); + } catch (error) { + // Branch might already exist, continue + console.log(`Branch creation error (during logo upload): ${error.message}`); + } + + // Read the file and upload it to GitHub + const logoContent = await fs.promises.readFile(logo.filepath); + + // Create a commit to add the logo file + await octokit.repos.createOrUpdateFileContents({ + owner: REPO_OWNER, + repo: REPO_NAME, + path: `public/logos/${logoFilename}`, + message: `Add logo for tool #${newId}`, + content: logoContent.toString('base64'), + branch: `tool-suggestion-${newId}`, + }); + } + + // Create a new tool object + const newTool = { + id: newId, + title, + url, + description, + tag: tags, + ...(logoFilename && { logo: logoFilename }), + ...(repo && { repo }), + }; + + // Add the new tool to the array + tools.push(newTool); + + // Convert tools array back to JSON string + const updatedToolsJsonContent = JSON.stringify(tools, null, 2); + + // Create a new branch for this PR + const branchName = `tool-suggestion-${newId}`; + const { data: mainRef } = await octokit.git.getRef({ + owner: REPO_OWNER, + repo: REPO_NAME, + ref: 'heads/main', + }); + + // Create a new branch + try { + await octokit.git.createRef({ + owner: REPO_OWNER, + repo: REPO_NAME, + ref: `refs/heads/${branchName}`, + sha: mainRef.object.sha, + }); + } catch (error) { + // Branch might already exist, continue with the process + console.log(`Branch creation error: ${error.message}`); + } + + // Update tools.json in the new branch + await octokit.repos.createOrUpdateFileContents({ + owner: REPO_OWNER, + repo: REPO_NAME, + path: 'public/tools.json', + message: `Add ${title} to tools.json`, + content: Buffer.from(updatedToolsJsonContent).toString('base64'), + branch: branchName, + sha: repoContent.sha, + }); + + // Create a pull request + const { data: pullRequest } = await octokit.pulls.create({ + owner: REPO_OWNER, + repo: REPO_NAME, + title: `Add ${title} to tools collection`, + body: `## New Tool Suggestion\n\n### Title\n${title}\n\n### Description\n${description}\n\n### URL\n${url}\n\n### Tags\n${tags.join(', ')}\n\n${repo ? `### Repository\n${repo}\n\n` : ''}Added via the Suggest a Tool form on MakerBench.`, + head: branchName, + base: 'main', + }); + + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Tool suggestion submitted successfully', + pullRequestUrl: pullRequest.html_url, + }), + }; + } catch (error) { + console.error('Error processing tool suggestion:', error); + + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Error processing tool suggestion', + error: error.message, + }), + }; + } +} + +// Parse multipart form data using formidable +async function parseMultipartForm(event) { + // Configure formidable for parsing the multipart form + const form = formidable({ + maxFileSize: 1024 * 1024, // 1MB file size limit + filter: (part) => { + // Filter files by allowed MIME types + if (part.mimetype) { + const allowedTypes = ['image/png', 'image/svg+xml', 'image/webp', 'image/avif']; + return allowedTypes.includes(part.mimetype); + } + return true; // Keep all non-file fields + }, + uploadDir: '/tmp', // Netlify Functions can only write to /tmp + keepExtensions: true, + }); + + return new Promise((resolve, reject) => { + // Create a stream from the event body + const bodyStream = new require('stream').Readable(); + + // Handle base64 encoded bodies (common in serverless functions) + const body = event.isBase64Encoded + ? Buffer.from(event.body, 'base64') + : event.body; + + bodyStream.push(body); + bodyStream.push(null); // Signal end of stream + + // Parse the stream + form.parse(bodyStream, (err, fields, files) => { + if (err) { + return reject(err); + } + + // Combine fields and files into a single object + const result = { ...fields }; + + // Add file information if it exists + if (files.logo) { + result.logo = files.logo; + } + + resolve(result); + }); + }); +} \ No newline at end of file diff --git a/package.json b/package.json index bd2d407..170b4ac 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "test": "playwright test" }, "dependencies": { + "@octokit/rest": "^20.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", "flexsearch": "^0.7.43", + "formidable": "^3.5.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.2.0" diff --git a/src/App.css b/src/App.css index 3bd5726..c1d674f 100644 --- a/src/App.css +++ b/src/App.css @@ -66,6 +66,14 @@ a:visited { width: 100%; } +.header-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 1rem 0; +} + .visually-hidden { border: 0; clip: rect(0 0 0 0); diff --git a/src/App.jsx b/src/App.jsx index 951f967..871370c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,8 @@ import { Footer } from "./ui/molecules/footer"; import { Logo } from "./ui/atoms/logo"; import { Search } from "./ui/molecules/search"; import { SearchResults } from "./ui/molecules/search-results"; +import { SuggestToolButton } from "./ui/molecules/suggest-tool-button"; +import { SuggestToolDialog } from "./ui/molecules/suggest-tool"; import "./App.css"; @@ -16,6 +18,7 @@ function DeveloperToolchest() { const [searchString, setSearchString] = React.useState(""); const [tools, setTools] = React.useState(null); const [toolsIndex, setToolsIndex] = React.useState(null); + const [isSuggestDialogOpen, setIsSuggestDialogOpen] = React.useState(false); function doSearch(query, event) { event && event.preventDefault(); @@ -37,6 +40,14 @@ function DeveloperToolchest() { setSearchString(event.target.value.toLowerCase()); } + function handleOpenSuggestDialog() { + setIsSuggestDialogOpen(true); + } + + function handleCloseSuggestDialog() { + setIsSuggestDialogOpen(false); + } + React.useEffect(() => { const jsonURL = import.meta.env.MODE === "testing" ? "/tools-test.json" : "/tools.json"; @@ -95,7 +106,10 @@ function DeveloperToolchest() { return (
- +
+ + +
+ + {/* Dialog for suggesting a tool */} +
); } diff --git a/src/ui/molecules/suggest-tool-button/index.css b/src/ui/molecules/suggest-tool-button/index.css new file mode 100644 index 0000000..e21cdee --- /dev/null +++ b/src/ui/molecules/suggest-tool-button/index.css @@ -0,0 +1,29 @@ +.suggest-tool-button { + background-color: #4a90e2; + color: white; + border: none; + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; +} + +.suggest-tool-button:hover { + background-color: #3a7bc8; +} + +.suggest-tool-button:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.4); +} + +/* Ensure the button is positioned appropriately on different screen sizes */ +@media (max-width: 768px) { + .suggest-tool-button { + font-size: 0.8rem; + padding: 0.4rem 0.8rem; + } +} \ No newline at end of file diff --git a/src/ui/molecules/suggest-tool-button/index.jsx b/src/ui/molecules/suggest-tool-button/index.jsx new file mode 100644 index 0000000..eaf7c53 --- /dev/null +++ b/src/ui/molecules/suggest-tool-button/index.jsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import "./index.css"; + +export function SuggestToolButton({ onClick }) { + return ( + + ); +} \ No newline at end of file diff --git a/src/ui/molecules/suggest-tool/index.css b/src/ui/molecules/suggest-tool/index.css new file mode 100644 index 0000000..9cf2946 --- /dev/null +++ b/src/ui/molecules/suggest-tool/index.css @@ -0,0 +1,174 @@ +.suggest-tool-dialog { + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + padding: 1.5rem; + border-radius: 8px; + border: 1px solid #e0e0e0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.suggest-tool-dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.dialog-header h2 { + margin: 0; + font-size: 1.5rem; + color: #333; +} + +.close-button { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + color: #666; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.close-button:hover { + color: #333; + background-color: #f5f5f5; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus { + border-color: #4a90e2; + outline: none; + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); +} + +.form-group input[aria-invalid="true"], +.form-group textarea[aria-invalid="true"] { + border-color: #e74c3c; +} + +.form-group input[type="file"] { + padding: 0.5rem 0; + border: none; +} + +.help-text { + margin-top: 0.25rem; + font-size: 0.875rem; + color: #666; +} + +.error-message { + margin-top: 0.25rem; + color: #e74c3c; + font-size: 0.875rem; +} + +.submit-error { + margin: 1rem 0; + padding: 0.75rem; + background-color: #fdecea; + border-radius: 4px; +} + +.button-group { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1.5rem; +} + +.primary-button, +.secondary-button { + padding: 0.75rem 1.25rem; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.primary-button { + background-color: #4a90e2; + color: white; + border: none; +} + +.primary-button:hover { + background-color: #3a7bc8; +} + +.primary-button:disabled { + background-color: #a0c3e8; + cursor: not-allowed; +} + +.secondary-button { + background-color: white; + color: #333; + border: 1px solid #ccc; +} + +.secondary-button:hover { + background-color: #f5f5f5; +} + +.success-message { + text-align: center; + padding: 2rem 1rem; +} + +.success-message h3 { + color: #27ae60; + margin-bottom: 1rem; +} + +.success-message p { + margin-bottom: 2rem; + color: #333; +} + +@media (max-width: 600px) { + .suggest-tool-dialog { + padding: 1rem; + width: 95%; + } + + .button-group { + flex-direction: column; + gap: 0.5rem; + } + + .button-group button { + width: 100%; + } +} \ No newline at end of file diff --git a/src/ui/molecules/suggest-tool/index.jsx b/src/ui/molecules/suggest-tool/index.jsx new file mode 100644 index 0000000..b420eb9 --- /dev/null +++ b/src/ui/molecules/suggest-tool/index.jsx @@ -0,0 +1,340 @@ +import * as React from "react"; +import "./index.css"; + +export function SuggestToolDialog({ isOpen, onClose }) { + const dialogRef = React.useRef(null); + const [formData, setFormData] = React.useState({ + title: "", + url: "", + description: "", + logo: null, + tag: "", + repo: "" + }); + const [errors, setErrors] = React.useState({}); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [submitSuccess, setSubmitSuccess] = React.useState(false); + + React.useEffect(() => { + const dialog = dialogRef.current; + + if (isOpen && dialog && !dialog.open) { + dialog.showModal(); + } else if (!isOpen && dialog && dialog.open) { + dialog.close(); + } + + return () => { + if (dialog && dialog.open) { + dialog.close(); + } + }; + }, [isOpen]); + + const handleInputChange = (e) => { + const { name, value, files } = e.target; + + if (name === 'logo' && files?.length) { + // Validate file size and type + const file = files[0]; + const validTypes = ['image/png', 'image/svg+xml', 'image/webp', 'image/avif']; + const maxSize = 1024 * 1024; // 1MB + + if (file.size > maxSize) { + setErrors(prev => ({ + ...prev, + logo: 'File size exceeds 1MB limit' + })); + return; + } + + if (!validTypes.includes(file.type)) { + setErrors(prev => ({ + ...prev, + logo: 'Only PNG, SVG, WebP, and AVIF formats are allowed' + })); + return; + } + + setFormData(prev => ({ + ...prev, + logo: file + })); + + // Clear error if valid + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors.logo; + return newErrors; + }); + } else { + setFormData(prev => ({ + ...prev, + [name]: value + })); + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.title.trim()) { + newErrors.title = 'Title is required'; + } + + if (!formData.url.trim()) { + newErrors.url = 'URL is required'; + } else if (!/^https?:\/\/.+\..+/.test(formData.url)) { + newErrors.url = 'Please enter a valid URL'; + } + + if (!formData.description.trim()) { + newErrors.description = 'Description is required'; + } + + if (!formData.tag.trim()) { + newErrors.tag = 'At least one tag is required'; + } + + if (formData.repo && !/^https?:\/\/.+\..+/.test(formData.repo)) { + newErrors.repo = 'Please enter a valid repository URL'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + // Prepare form data for submission + const submitData = new FormData(); + + // Add all form fields + Object.entries(formData).forEach(([key, value]) => { + if (key === 'tag') { + // Convert comma-separated tags to an array + submitData.append(key, JSON.stringify(value.split(',').map(tag => tag.trim()))); + } else if (key !== 'logo' || value !== null) { + submitData.append(key, value); + } + }); + + // Submit to Netlify function + const response = await fetch('/.netlify/functions/suggest-tool', { + method: 'POST', + body: submitData + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to submit tool suggestion'); + } + + // Show success state + setSubmitSuccess(true); + + // Reset form after successful submission + setTimeout(() => { + resetForm(); + }, 3000); + } catch (error) { + console.error('Error submitting tool suggestion:', error); + setErrors(prev => ({ + ...prev, + submit: error.message || 'Failed to submit. Please try again.' + })); + } finally { + setIsSubmitting(false); + } + }; + + const resetForm = () => { + setFormData({ + title: "", + url: "", + description: "", + logo: null, + tag: "", + repo: "" + }); + setErrors({}); + setSubmitSuccess(false); + }; + + const handleDialogClose = () => { + resetForm(); + onClose(); + }; + + return ( + +
+

Suggest a Tool

+ +
+ + {submitSuccess ? ( +
+

Thank you for your suggestion!

+

Your tool has been submitted for review and will be added to MakerBench soon.

+ +
+ ) : ( +
+
+ + + {errors.title && ( +

{errors.title}

+ )} +
+ +
+ + + {errors.url && ( +

{errors.url}

+ )} +
+ +
+ +