diff --git a/.gitignore b/.gitignore index 6d98d03..42dcbfb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ data/fetch-progress.json data/geocode-cache.json data/pipeline.log *.log +dist/ diff --git a/app/api/nominate/route.js b/app/api/nominate/route.js new file mode 100644 index 0000000..bed50f5 --- /dev/null +++ b/app/api/nominate/route.js @@ -0,0 +1,17 @@ +/** + * Next.js API route — "Add me to DevGlobe" self-nomination + * + * Endpoint: POST /api/nominate + * Body: { username: string, location?: string } + * + * Validates the GitHub username via the GitHub API and stores the + * nomination in the Cosmos DB 'nominations' container for admin review. + */ +import { NextResponse } from 'next/server'; +import { submitNomination } from '../../../lib/nominate.js'; + +export async function POST(request) { + const { username, location } = await request.json().catch(() => ({})); + const result = await submitNomination({ username, location }); + return NextResponse.json(result.body, { status: result.status }); +} diff --git a/app/page.jsx b/app/page.jsx index 54d24ed..6068110 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -7,6 +7,7 @@ import Leaderboard from '../components/Leaderboard.jsx'; import DetailPanel from '../components/DetailPanel.jsx'; import ComparePanel from '../components/ComparePanel.jsx'; import LoadingOverlay from '../components/LoadingOverlay.jsx'; +import AddMeModal from '../components/AddMeModal.jsx'; import { scoreAll } from '../lib/scoring.js'; import { addDeveloperRanks } from '../lib/ranking.js'; import dynamic from 'next/dynamic'; @@ -29,6 +30,7 @@ export default function Home() { const [sidebarOpen, setSidebarOpen] = useState(false); const [cardRequest, setCardRequest] = useState(0); const [cardContext, setCardContext] = useState(null); + const [showAddMe, setShowAddMe] = useState(false); const globeRef = useRef(null); useEffect(() => { @@ -231,6 +233,14 @@ export default function Home() { setCompareDevs([]); }, []); + const handleAddMe = useCallback(() => { + setShowAddMe(true); + }, []); + + const handleCloseAddMe = useCallback(() => { + setShowAddMe(false); + }, []); + const handleHome = useCallback(() => { setCardRequest(0); setCardContext(null); @@ -258,6 +268,7 @@ export default function Home() { claimStatus={claimStatus} sidebarOpen={sidebarOpen} onToggleSidebar={handleToggleSidebar} + onAddMe={handleAddMe} /> )} + {showAddMe && } ); diff --git a/components/AddMeModal.jsx b/components/AddMeModal.jsx new file mode 100644 index 0000000..f87402b --- /dev/null +++ b/components/AddMeModal.jsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect, useRef } from 'react'; +import styles from './AddMeModal.module.css'; + +const SUCCESS_MESSAGE = "Thanks! We'll review and add you within a week."; + +export default function AddMeModal({ onClose }) { + const [username, setUsername] = useState(''); + const [location, setLocation] = useState(''); + const [status, setStatus] = useState('idle'); // idle | submitting | success | error + const [error, setError] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + const onKeyDown = (e) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (status === 'submitting') return; + + const clean = username.trim().replace(/^@/, ''); + if (!clean) { + setStatus('error'); + setError('Please enter your GitHub username.'); + return; + } + + setStatus('submitting'); + setError(''); + try { + const res = await fetch('/api/nominate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: clean, location: location.trim() }), + }); + const data = await res.json(); + if (!res.ok) { + setStatus('error'); + setError(data.error || 'Something went wrong. Please try again.'); + return; + } + setStatus('success'); + } catch (err) { + setStatus('error'); + setError('Network error. Please try again.'); + } + }; + + return ( +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add me to DevGlobe"> + + + {status === 'success' ? ( +
+
🎉
+

You're on the list!

+

{SUCCESS_MESSAGE}

+ +
+ ) : ( + <> +

Add me to DevGlobe

+

+ Submit your GitHub username to be featured on the globe. We'll review and add you within a week. +

+
+ + setUsername(e.target.value)} + autoComplete="off" + spellCheck="false" + /> + + + setLocation(e.target.value)} + autoComplete="off" + /> + + {status === 'error' &&
{error}
} + + +
+ + )} +
+
+ ); +} diff --git a/components/AddMeModal.module.css b/components/AddMeModal.module.css new file mode 100644 index 0000000..9665974 --- /dev/null +++ b/components/AddMeModal.module.css @@ -0,0 +1,143 @@ +.modal-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 14, 23, 0.7); + backdrop-filter: blur(4px); + z-index: 1000; + padding: 16px; +} + +.modal { + position: relative; + width: 100%; + max-width: 420px; + max-height: 90vh; + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 28px; + animation: modalIn 0.18s ease-out; +} + +@keyframes modalIn { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +.modal__close { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: 50%; + color: var(--text-muted); + font-size: 14px; + cursor: pointer; +} + +.modal__close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.modal__title { + font-size: 20px; + font-weight: 700; + margin-bottom: 8px; +} + +.modal__subtitle { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 20px; +} + +.modal__form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal__label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.modal__required { + color: #ef4444; +} + +.modal__optional { + color: var(--text-muted); + font-weight: 400; +} + +.modal__input { + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + font-family: var(--font); + outline: none; + transition: border-color 0.2s; +} + +.modal__input:focus { + border-color: var(--accent-blue); +} + +.modal__error { + padding: 10px 12px; + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: 8px; + color: #fca5a5; + font-size: 13px; +} + +.modal__submit { + margin-top: 4px; + padding: 10px 14px; + border-radius: 8px; +} + +.modal__success { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 12px 0; +} + +.modal__success-icon { + font-size: 44px; +} + +.modal__success .modal__title { + margin-bottom: 0; +} + +.modal__message { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} diff --git a/components/Header.jsx b/components/Header.jsx index 8f61a7e..5331bc5 100644 --- a/components/Header.jsx +++ b/components/Header.jsx @@ -3,7 +3,7 @@ import React from 'react'; import UserMenu from './UserMenu.jsx'; -export default function Header({ onHome, theme, onToggleTheme, user, onLogout, onClaim, claimStatus, sidebarOpen, onToggleSidebar }) { +export default function Header({ onHome, theme, onToggleTheme, user, onLogout, onClaim, claimStatus, sidebarOpen, onToggleSidebar, onAddMe }) { return (
@@ -12,6 +12,11 @@ export default function Header({ onHome, theme, onToggleTheme, user, onLogout, o Where Developers and AI Agents Connect
+