added Material UI support

This commit is contained in:
2025-10-19 19:46:30 -05:00
committed by Jason Ross
parent 31a10f3eb4
commit 70d1764801
9 changed files with 2055 additions and 1075 deletions
+4 -1
View File
@@ -16,7 +16,10 @@
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json"
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
+22 -10
View File
@@ -21,22 +21,34 @@
"autofill"
],
"author": "",
"license": "ISC",
"license": "MIT",
"packageManager": "pnpm@10.18.1",
"devDependencies": {
"@types/firefox-webext-browser": "^120.0.4",
"@types/node": "^20.14.9",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vitest/ui": "^1.6.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"@types/firefox-webext-browser": "^143.0.0",
"@types/node": "^24.8.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"jsdom": "^27.0.1",
"prettier": "^3.3.2",
"typescript": "^5.5.3",
"vite": "^5.3.2",
"vite": "^7.1.10",
"vite-plugin-web-extension": "^4.1.6",
"vitest": "^1.6.0"
"vitest": "^3.2.4"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
+1669 -811
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
+2 -52
View File
@@ -4,59 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JobSpeed</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="popup-container">
<header>
<h1>JobSpeed</h1>
<p class="subtitle">Auto-fill job applications</p>
</header>
<main>
<section class="resume-section">
<h2>Resume</h2>
<div id="resume-status" class="status-container">
<p class="status-text">No resume uploaded</p>
</div>
<div class="upload-container">
<input
type="file"
id="resume-file-input"
accept=".pdf,.txt,.doc,.docx"
style="display: none"
/>
<button id="upload-button" class="primary-button">
Upload Resume
</button>
</div>
<div id="resume-preview" class="resume-preview hidden">
<h3>Resume Data</h3>
<div id="resume-data"></div>
<button id="clear-resume-button" class="secondary-button">
Clear Resume
</button>
</div>
</section>
<section class="actions-section">
<h2>Actions</h2>
<button id="scan-page-button" class="primary-button">
Scan Current Page
</button>
<p class="help-text">
Click to detect form fields on the current page and preview
auto-fill suggestions
</p>
</section>
</main>
<footer>
<p class="version">v1.0.0</p>
</footer>
</div>
<script src="popup.ts" type="module"></script>
<div id="root"></div>
<script src="popup.tsx" type="module"></script>
</body>
</html>
-201
View File
@@ -1,201 +0,0 @@
import type { ResumeData, MessageResponse } from './types';
document.addEventListener('DOMContentLoaded', () => {
const uploadButton = document.getElementById(
'upload-button'
) as HTMLButtonElement;
const resumeFileInput = document.getElementById(
'resume-file-input'
) as HTMLInputElement;
const resumeStatus = document.getElementById('resume-status');
const resumePreview = document.getElementById('resume-preview');
const resumeDataDiv = document.getElementById('resume-data');
const clearResumeButton = document.getElementById(
'clear-resume-button'
) as HTMLButtonElement;
const scanPageButton = document.getElementById(
'scan-page-button'
) as HTMLButtonElement;
// Load and display existing resume data
void loadResumeStatus();
uploadButton.addEventListener('click', () => {
resumeFileInput.click();
});
resumeFileInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
void (async () => {
try {
const text = await file.text();
const resumeData = parseResume(text);
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: resumeData,
});
await loadResumeStatus();
alert('Resume uploaded successfully!');
} catch (error) {
console.error('Error uploading resume:', error);
alert('Error uploading resume. Please try again.');
}
})();
});
clearResumeButton.addEventListener('click', () => {
if (!confirm('Are you sure you want to clear your resume data?')) {
return;
}
void (async () => {
try {
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: null,
});
await loadResumeStatus();
alert('Resume cleared successfully!');
} catch (error) {
console.error('Error clearing resume:', error);
alert('Error clearing resume. Please try again.');
}
})();
});
scanPageButton.addEventListener('click', () => {
void (async () => {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const activeTab = tabs[0];
if (!activeTab?.id) {
alert('No active tab found');
return;
}
await browser.tabs.sendMessage(activeTab.id, {
type: 'SCAN_PAGE',
});
window.close();
} catch (error) {
console.error('Error scanning page:', error);
alert('Error scanning page. Please try again.');
}
})();
});
async function loadResumeStatus() {
try {
const response = (await browser.runtime.sendMessage({
type: 'GET_RESUME_DATA',
})) as MessageResponse;
if (response.success && response.data) {
const resumeData = response.data as ResumeData;
displayResumeData(resumeData);
resumeStatus?.classList.add('has-resume');
if (resumeStatus) {
const statusText = resumeStatus.querySelector('.status-text');
if (statusText) {
statusText.textContent = 'Resume uploaded ✓';
}
}
resumePreview?.classList.remove('hidden');
if (scanPageButton) {
scanPageButton.disabled = false;
}
} else {
resumeStatus?.classList.remove('has-resume');
if (resumeStatus) {
const statusText = resumeStatus.querySelector('.status-text');
if (statusText) {
statusText.textContent = 'No resume uploaded';
}
}
resumePreview?.classList.add('hidden');
if (scanPageButton) {
scanPageButton.disabled = true;
}
}
} catch (error) {
console.error('Error loading resume status:', error);
}
}
function displayResumeData(data: ResumeData) {
if (!resumeDataDiv) return;
const html = `
<p><strong>Name:</strong> ${data.personalInfo.fullName}</p>
<p><strong>Email:</strong> ${data.personalInfo.email}</p>
<p><strong>Phone:</strong> ${data.personalInfo.phone}</p>
<p><strong>Location:</strong> ${data.personalInfo.city}, ${data.personalInfo.state}</p>
<p><strong>Skills:</strong> ${data.skills.slice(0, 5).join(', ')}${data.skills.length > 5 ? '...' : ''}</p>
`;
resumeDataDiv.innerHTML = html;
}
function parseResume(text: string): ResumeData {
// Basic resume parser - extracts email, phone, and text content
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/g;
const phoneRegex =
/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
const email = text.match(emailRegex)?.[0] || '';
const phone = text.match(phoneRegex)?.[0] || '';
// Extract name (assume first line is name)
const lines = text.split('\n').filter((line) => line.trim());
const fullName = lines[0] || '';
// Basic skill extraction (look for common keywords)
const skillKeywords = [
'JavaScript',
'TypeScript',
'Python',
'Java',
'React',
'Node.js',
'SQL',
'HTML',
'CSS',
'Git',
'AWS',
'Azure',
'Docker',
'Kubernetes',
];
const skills = skillKeywords.filter((skill) =>
text.toLowerCase().includes(skill.toLowerCase())
);
return {
personalInfo: {
fullName,
email,
phone,
address: '',
city: '',
state: '',
zipCode: '',
country: '',
},
workExperience: [],
education: [],
skills,
summary: text.substring(0, 500),
};
}
});
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import {
ThemeProvider,
createTheme,
CssBaseline,
Box,
AppBar,
Toolbar,
Typography,
Paper,
Button,
Alert,
Chip,
Stack,
Divider,
} from '@mui/material';
import {
Upload as UploadIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Description as DescriptionIcon,
} from '@mui/icons-material';
import type { ResumeData } from './types';
// Create a custom theme for the extension
const theme = createTheme({
palette: {
primary: {
main: '#667eea',
},
secondary: {
main: '#764ba2',
},
background: {
default: '#f5f5f5',
},
},
typography: {
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif',
},
});
const PopupApp: React.FC = () => {
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
useEffect(() => {
void loadResumeStatus();
}, []);
const loadResumeStatus = async () => {
try {
const response = (await browser.runtime.sendMessage({
type: 'GET_RESUME_DATA',
})) as { success: boolean; data?: ResumeData | null };
if (response.success && response.data) {
setResumeData(response.data);
}
} catch (error) {
console.error('Error loading resume:', error);
}
};
const handleUploadResume = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.txt,.doc,.docx';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsedData = parseResume(text);
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: parsedData,
});
await loadResumeStatus();
alert('Resume uploaded successfully!');
} catch (error) {
console.error('Error uploading resume:', error);
alert('Error uploading resume. Please try again.');
}
};
input.click();
};
const handleClearResume = async () => {
if (!confirm('Are you sure you want to clear your resume data?')) {
return;
}
try {
await browser.runtime.sendMessage({
type: 'SAVE_RESUME_DATA',
data: null,
});
await loadResumeStatus();
alert('Resume cleared successfully!');
} catch (error) {
console.error('Error clearing resume:', error);
alert('Error clearing resume. Please try again.');
}
};
const handleScanPage = async () => {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const activeTab = tabs[0];
if (!activeTab?.id) {
alert('No active tab found');
return;
}
await browser.tabs.sendMessage(activeTab.id, {
type: 'SCAN_PAGE',
});
window.close();
} catch (error) {
console.error('Error scanning page:', error);
alert('Error scanning page. Please try again.');
}
};
const parseResume = (text: string): ResumeData => {
const lines = text.split('\n').filter((line) => line.trim());
return {
personalInfo: {
fullName:
lines
.find((line) => line.match(/^[A-Z][a-z]+ [A-Z][a-z]+/))
?.trim() || '',
email:
lines
.find((line) =>
line.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/)
)
?.trim() || '',
phone:
lines
.find((line) =>
line.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)
)
?.trim() || '',
address:
lines
.find((line) => line.match(/\d+\s+\w+\s+(St|Ave|Rd|Blvd|Ln|Dr)/i))
?.trim() || '',
city: '',
state: '',
zipCode: '',
country: '',
},
workExperience: [],
education: [],
skills: [],
summary: '',
};
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ width: 400, bgcolor: 'background.default' }}>
{/* Header */}
<AppBar
position="static"
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Toolbar>
<Box sx={{ flexGrow: 1, textAlign: 'center' }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 600 }}>
JobSpeed
</Typography>
<Typography variant="caption" sx={{ opacity: 0.9 }}>
Auto-fill job applications
</Typography>
</Box>
</Toolbar>
</AppBar>
{/* Main Content */}
<Box sx={{ p: 2.5 }}>
{/* Resume Section */}
<Paper elevation={0} sx={{ p: 2, mb: 3, bgcolor: 'white' }}>
<Stack spacing={2}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Resume
</Typography>
{resumeData && (
<Chip
icon={<DescriptionIcon />}
label="Uploaded"
color="success"
size="small"
/>
)}
</Box>
{!resumeData ? (
<Alert severity="info" sx={{ mb: 1 }}>
No resume uploaded
</Alert>
) : (
<Alert severity="success" sx={{ mb: 1 }}>
Resume data loaded
</Alert>
)}
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={handleUploadResume}
fullWidth
sx={{
background:
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
textTransform: 'none',
py: 1.5,
}}
>
Upload Resume
</Button>
{resumeData && (
<>
<Divider />
<Box sx={{ bgcolor: '#f8f9fa', p: 2, borderRadius: 1 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1, fontWeight: 600 }}
>
Resume Data
</Typography>
<Stack
spacing={0.5}
sx={{ fontSize: '0.875rem', color: '#666' }}
>
{resumeData.personalInfo.fullName && (
<Typography variant="body2">
<strong>Name:</strong>{' '}
{resumeData.personalInfo.fullName}
</Typography>
)}
{resumeData.personalInfo.email && (
<Typography variant="body2">
<strong>Email:</strong>{' '}
{resumeData.personalInfo.email}
</Typography>
)}
{resumeData.personalInfo.phone && (
<Typography variant="body2">
<strong>Phone:</strong>{' '}
{resumeData.personalInfo.phone}
</Typography>
)}
{resumeData.personalInfo.address && (
<Typography variant="body2">
<strong>Address:</strong>{' '}
{resumeData.personalInfo.address}
</Typography>
)}
</Stack>
</Box>
<Button
variant="outlined"
startIcon={<DeleteIcon />}
onClick={() => void handleClearResume()}
fullWidth
color="error"
sx={{ textTransform: 'none' }}
>
Clear Resume
</Button>
</>
)}
</Stack>
</Paper>
{/* Actions Section */}
<Paper elevation={0} sx={{ p: 2, bgcolor: 'white' }}>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Actions
</Typography>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => void handleScanPage()}
fullWidth
sx={{
background:
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
textTransform: 'none',
py: 1.5,
}}
>
Scan Current Page
</Button>
<Typography variant="caption" color="text.secondary">
Click to detect form fields on the current page and preview
auto-fill suggestions
</Typography>
</Stack>
</Paper>
</Box>
{/* Footer */}
<Box
sx={{
p: 1.5,
textAlign: 'center',
borderTop: '1px solid',
borderColor: 'divider',
bgcolor: '#f8f9fa',
}}
>
<Typography variant="caption" color="text.secondary">
v1.0.0
</Typography>
</Box>
</Box>
</ThemeProvider>
);
};
// Mount the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<PopupApp />);
}
+1
View File
@@ -5,6 +5,7 @@
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
+2
View File
@@ -1,5 +1,6 @@
import { defineConfig } from 'vite';
import webExtension from 'vite-plugin-web-extension';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -8,6 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: 'src',
plugins: [
react(),
webExtension({
manifest: 'manifest.json',
watchFilePaths: ['**/*'],