diff --git a/.gitignore b/.gitignore index a547bf3..1ccaaea 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? + +# Local Netlify folder +.netlify + +# Todo file +todo.md 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..f740518 --- /dev/null +++ b/netlify/functions/suggest-tool.js @@ -0,0 +1,296 @@ +// Netlify serverless function to handle tool suggestion submissions +import { Octokit } from '@octokit/rest'; +import fs from 'fs'; +import path from 'path'; +import { Buffer } from 'buffer'; + +// GitHub repo info +const REPO_OWNER = 'schalkneethling'; +const REPO_NAME = 'makerbench'; + +export async function handler(event) { + // Only allow POST requests + if (event.httpMethod !== 'POST') { + return { + statusCode: 405, + body: JSON.stringify({ message: 'Method Not Allowed' }), + }; + } + + try { + console.log('Processing incoming request with headers:', JSON.stringify(event.headers)); + console.log('Content type:', event.headers['content-type'] || event.headers['Content-Type']); + + // Parse form data + console.log('About to parse multipart form data'); + const formData = await parseMultipartForm(event); + console.log('Form data parsed successfully:', JSON.stringify(formData, null, 2)); + 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); + console.error('Error stack:', error.stack); + + // Provide more debugging info in development + let errorDetails = { + message: 'Error processing tool suggestion', + error: error.message + }; + + // Add more details for debugging but be careful not to expose sensitive info + if (process.env.NODE_ENV !== 'production') { + errorDetails.stack = error.stack; + errorDetails.eventHeaders = event.headers; + errorDetails.eventHttpMethod = event.httpMethod; + } + + return { + statusCode: 500, + body: JSON.stringify(errorDetails), + }; + } +} + +// Parse multipart form data without using formidable +async function parseMultipartForm(event) { + return new Promise((resolve, reject) => { + try { + // Get the content type and boundary + const contentType = event.headers['content-type'] || event.headers['Content-Type']; + + if (!contentType || !contentType.includes('multipart/form-data')) { + return reject(new Error('Not a multipart form data submission')); + } + + // Get the boundary from the content type + const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + if (!boundaryMatch) { + return reject(new Error('No boundary found in content type')); + } + + const boundary = boundaryMatch[1] || boundaryMatch[2]; + + // Get the body (handle base64 encoding if necessary) + const body = event.isBase64Encoded + ? Buffer.from(event.body, 'base64').toString('utf8') + : event.body; + + if (!body) { + return reject(new Error('Request body is empty or malformed')); + } + + // Split the body by boundary + const boundaryString = `--${boundary}`; + const parts = body.split(boundaryString).filter(part => + part.trim() !== '' && part.trim() !== '--' + ); + + // Process each part + const formData = {}; + let logoData = null; + + for (const part of parts) { + // Get the headers of the part + const [headerSection, ...contentSections] = part.split('\r\n\r\n'); + const content = contentSections.join('\r\n\r\n').trim(); + + // Check if this is a file input or a normal field + const nameMatch = headerSection.match(/name="([^"]+)"/); + const filenameMatch = headerSection.match(/filename="([^"]+)"/); + + if (!nameMatch) continue; // Skip if no name found + + const name = nameMatch[1]; + + if (filenameMatch) { + // This is a file input + const filename = filenameMatch[1]; + + if (name === 'logo' && filename) { + // Check MIME type (simple check based on file extension) + const ext = path.extname(filename).toLowerCase(); + const allowedExts = ['.png', '.svg', '.webp', '.avif']; + + if (allowedExts.includes(ext)) { + // Create a temp file + const tempPath = `/tmp/${Date.now()}-${filename}`; + + // The content includes the trailing \r\n so we need to remove it + const fileContent = content.replace(/\r\n$/, ''); + + // Write the file + fs.writeFileSync(tempPath, fileContent); + + // Store file info + logoData = { + originalFilename: filename, + filepath: tempPath, + // Simplified mimetype detection + mimetype: ext === '.png' ? 'image/png' : + ext === '.svg' ? 'image/svg+xml' : + ext === '.webp' ? 'image/webp' : 'image/avif' + }; + } + } + } else { + // This is a regular field + formData[name] = content; + } + } + + // Add logo if it exists + if (logoData) { + formData.logo = logoData; + } + + resolve(formData); + } catch (error) { + console.error('Error parsing multipart form:', error); + reject(error); + } + }); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d1a17d6..7833ecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "developer-toolchest-vite", "version": "0.0.0", "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", @@ -954,6 +955,161 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-23.0.1.tgz", + "integrity": "sha512-izFjMJ1sir0jn0ldEKhZ7xegCTj/ObmEDlEfpFrx4k/JyZSMRHbO3/rBwgE7f3m2DHt+RrNGIVw4wSmwnm3t/g==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", + "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.8.0.tgz", + "integrity": "sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^23.0.1" + } + }, "node_modules/@playwright/test": { "version": "1.50.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", @@ -1669,6 +1825,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1947,6 +2109,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "license": "ISC" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3517,7 +3685,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -4414,6 +4581,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, "node_modules/update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", @@ -4620,8 +4793,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/yallist": { "version": "3.1.1", diff --git a/package.json b/package.json index bd2d407..48b07c9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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", diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..e7430ae --- /dev/null +++ b/spec.md @@ -0,0 +1,77 @@ +# Suggest a tool + +MakerBench is an online web app that allows developers and designers to add tools to a list maintained as a JSON file in `public/tools.json`. Each tool consists of the following: + +```json +{ + "id": 0, + "title": "Codepen.io", + "url": "https://codepen.io/", + "description": "CodePen is a social development environment for front-end designers and developers. Build and deploy a website, show off your work, build test cases to learn and debug, and find inspiration.", + "logo": "codepen.png", + "tag": ["repl", "editor"], + "repo": "https://github.com/codepen" +} +``` + +> Note: The logo is optional, but when provided should ideally be in SVG or WebP format and should be placed in the `public/logos` directory. The name in the `logo` field should match the filename. + +## The challenge + +At the moment the JSON file is maintained manually. If a contributor wishes to propose a new tool they need to do so by: + +1. Forking the repository +2. Adding the tool to the JSON file +3. Creating a pull request + +I suspect this is part of the reason why I recieve so few contributions and why I also do not add tools to the list as often as I probably would if the process was easier. + +## A possible solution + +When a user lands on MakerBench there is a button at the top right titled, "Suggest a Tool". When clicked, an HTML ` modal is opened and the user is presented with a form that allows them to suggest a tool to the list. The form should have the following fields: + +- Title [A text input] +- URL [A text input with `type="url"`] +- Description [A textarea] +- Logo [A file input - optional with a max size of 1MB. Also, only PNG, SVG, WebP, and AVIF formats are allowed] +- Tag [A text input that allows the user to add tags separated by commas] +- Repository [A text input with `type="url"` which is optional] + +When the user submits the form, the data should be sent to a Netlify serverless function that will: + +1. Validate the data +2. Save the logo to the `public/logos` directory +3. Add the tool to the JSON file +4. Ensure the ID is unique and increment it by 1 +5. Create a new commit with the changes +6. Create a pull request +7. Send an email to the maintainer of the repository with the details of the new tool + +### Some additional notes + +- Before getting started, take a moment to get familiar with the existing code base and the various technologies used. +- When the user submits the form, they should see a success message and the form shoudl reset to its initial state. If there are any errors, the form should be updated to show the errors. +- The `` should also contain a close button that allows the user to close the modal. +- While this web app is written in React, aim to use as much of the standard web platform as possible and only lean on React and other external libraries as needed and when it will make the task easier. +- Accessibility of all functionality should be top of mind for the implementation. +- Ensure that there are tests in place to validate the functionality as much as reasonably possible. There are already some tests in place using Playwright in `tests`. + +## Success criteria + +- The user can suggest a tool using the form +- The form is validated and the user is informed of any errors +- The tool is added to the JSON file +- A pull request is created +- An email is sent to the maintainer +- The form resets after a successfull submission +- The `` can be closed +- The implementation is accessible +- There are tests in place to validate the functionality + +### In closing + +Once the work on this feature is complete, please commit and push all the changes to GitHub and create a pull request. There is an issue for this [work on GitHub](https://github.com/schalkneethling/makerbench/issues/493). Please ensure that the work is done on a feature branch that is named `493-suggest-a-tool`. + +> Note: Ignore the details in the linked issue and refer to this specification document for the details of the work to be done. + +I will review the changes and provide feedback as needed. Once everything is good to go, I will merge the changes and deploy the new version of MakerBench. Thank you for your hard work on this feature! 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/article-card/index.jsx b/src/ui/molecules/article-card/index.jsx index b38dd1a..26aa6c9 100644 --- a/src/ui/molecules/article-card/index.jsx +++ b/src/ui/molecules/article-card/index.jsx @@ -1,5 +1,10 @@ import "./index.css"; +import PropTypes from "prop-types"; export const ArticleCard = ({ children }) => { return
{children}
; }; + +ArticleCard.propTypes = { + children: PropTypes.node.isRequired +}; diff --git a/src/ui/molecules/search/index.jsx b/src/ui/molecules/search/index.jsx index 52d0386..ba6532f 100644 --- a/src/ui/molecules/search/index.jsx +++ b/src/ui/molecules/search/index.jsx @@ -1,3 +1,4 @@ +import PropTypes from "prop-types"; import { Suggestions } from "../suggestions"; import "./index.css"; @@ -42,3 +43,10 @@ export const Search = ({ ); }; + +Search.propTypes = { + handleChange: PropTypes.func.isRequired, + onSubmitCallback: PropTypes.func.isRequired, + searchString: PropTypes.string.isRequired, + tools: PropTypes.array +}; diff --git a/src/ui/molecules/sponsor-card/index.jsx b/src/ui/molecules/sponsor-card/index.jsx index 337be4a..bc6f289 100644 --- a/src/ui/molecules/sponsor-card/index.jsx +++ b/src/ui/molecules/sponsor-card/index.jsx @@ -1,5 +1,10 @@ import "./index.css"; +import PropTypes from "prop-types"; export const SponsorCard = ({ children }) => { return
{children}
; }; + +SponsorCard.propTypes = { + children: PropTypes.node.isRequired +}; 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..2964941 --- /dev/null +++ b/src/ui/molecules/suggest-tool-button/index.jsx @@ -0,0 +1,19 @@ +import "./index.css"; +import PropTypes from "prop-types"; + +export function SuggestToolButton({ onClick }) { + return ( + + ); +} + +SuggestToolButton.propTypes = { + onClick: PropTypes.func.isRequired +}; \ 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..607519d --- /dev/null +++ b/src/ui/molecules/suggest-tool/index.jsx @@ -0,0 +1,346 @@ +import * as React from "react"; +import PropTypes from "prop-types"; +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}

+ )} +
+ +
+ +