Initial commit for resumeformatter project
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
105
frontend/App.tsx
Normal file
105
frontend/App.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { Person, NewPerson } from './types';
|
||||
import { getPeople, addPerson } from './services/apiService';
|
||||
import PersonList from './components/PersonList';
|
||||
import AddPersonForm from './components/AddPersonForm';
|
||||
import Modal from './components/Modal';
|
||||
import { PlusIcon, UsersIcon } from './components/Icons';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
|
||||
const fetchPeople = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getPeople();
|
||||
setPeople(data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch people. Please try again later.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeople();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAddPerson = async (newPerson: NewPerson) => {
|
||||
try {
|
||||
const addedPerson = await addPerson(newPerson);
|
||||
setPeople((prevPeople) => [...prevPeople, addedPerson]);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
setError('Failed to add person. Please try again.');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return <p className="text-center text-gray-500 dark:text-gray-400 mt-8">Loading profiles...</p>;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-center text-red-500 dark:text-red-400 mt-8">{error}</p>;
|
||||
}
|
||||
if (people.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 px-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<UsersIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-lg font-medium text-gray-900 dark:text-white">No profiles yet</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new profile.</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <PersonList people={people} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans">
|
||||
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<header className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Profile Linker
|
||||
</h1>
|
||||
{people.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 mr-2 -ml-1" />
|
||||
<span>Add Profile</span>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Add New Profile">
|
||||
<AddPersonForm onSubmit={handleAddPerson} onCancel={() => setIsModalOpen(false)} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
frontend/README.md
Normal file
20
frontend/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://gitea.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1fGMiOc3HONBnOp0qMiclJ_huKwQg8ukW
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
110
frontend/api_spec.md
Normal file
110
frontend/api_spec.md
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
# Profile Linker API Specification
|
||||
|
||||
This document outlines the API endpoints required for the Profile Linker application. The API is responsible for managing a list of people's profiles.
|
||||
|
||||
## Base URL
|
||||
|
||||
The API endpoints are relative to a base URL, e.g., `https://api.example.com`.
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints could be protected by an authentication mechanism (e.g., OAuth 2.0, API Key in header), which is not detailed in this specification. For this example, we assume endpoints are publicly accessible.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Person Object
|
||||
|
||||
Represents a single person's profile.
|
||||
|
||||
| Field | Type | Description | Example |
|
||||
|---------------|--------|----------------------------------------|----------------------------------------|
|
||||
| `id` | string | Unique identifier for the person | `"a1b2c3d4-e5f6-7890-1234-567890abcdef"` |
|
||||
| `firstName` | string | The person's first name | `"Jane"` |
|
||||
| `lastName` | string | The person's last name | `"Doe"` |
|
||||
| `linkedinUrl` | string | Full URL to the person's LinkedIn profile | `"https://www.linkedin.com/in/janedoe"` |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Get All People
|
||||
|
||||
Retrieves a list of all people in the database.
|
||||
|
||||
- **URL:** `/api/people`
|
||||
- **Method:** `GET`
|
||||
- **Success Response:**
|
||||
- **Code:** `200 OK`
|
||||
- **Content:** An array of Person objects.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"linkedinUrl": "https://www.linkedin.com/in/ada-lovelace"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"firstName": "Grace",
|
||||
"lastName": "Hopper",
|
||||
"linkedinUrl": "https://www.linkedin.com/in/grace-hopper"
|
||||
}
|
||||
]
|
||||
```
|
||||
- **Error Response:**
|
||||
- **Code:** `500 Internal Server Error`
|
||||
- **Content:**
|
||||
```json
|
||||
{
|
||||
"error": "An unexpected error occurred."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Add a New Person
|
||||
|
||||
Creates a new person record in the database.
|
||||
|
||||
- **URL:** `/api/people`
|
||||
- **Method:** `POST`
|
||||
- **Request Body:** A JSON object containing the new person's details (excluding the `id`).
|
||||
```json
|
||||
{
|
||||
"firstName": "Margaret",
|
||||
"lastName": "Hamilton",
|
||||
"linkedinUrl": "https://www.linkedin.com/in/margaret-hamilton"
|
||||
}
|
||||
```
|
||||
- **Success Response:**
|
||||
- **Code:** `201 Created`
|
||||
- **Content:** The newly created Person object, including its server-generated `id`.
|
||||
```json
|
||||
{
|
||||
"id": "3",
|
||||
"firstName": "Margaret",
|
||||
"lastName": "Hamilton",
|
||||
"linkedinUrl": "https://www.linkedin.com/in/margaret-hamilton"
|
||||
}
|
||||
```
|
||||
- **Error Responses:**
|
||||
- **Code:** `400 Bad Request` (e.g., for missing fields or invalid data)
|
||||
- **Content:**
|
||||
```json
|
||||
{
|
||||
"error": "Validation failed.",
|
||||
"details": {
|
||||
"firstName": "First name is required.",
|
||||
"linkedinUrl": "A valid URL is required."
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Code:** `500 Internal Server Error`
|
||||
- **Content:**
|
||||
```json
|
||||
{
|
||||
"error": "Failed to create the person."
|
||||
}
|
||||
```
|
||||
104
frontend/components/AddPersonForm.tsx
Normal file
104
frontend/components/AddPersonForm.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import type { NewPerson } from '../types';
|
||||
|
||||
interface AddPersonFormProps {
|
||||
onSubmit: (person: NewPerson) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddPersonForm: React.FC<AddPersonFormProps> = ({ onSubmit, onCancel }) => {
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [linkedinUrl, setLinkedinUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!firstName || !lastName || !linkedinUrl) {
|
||||
setError('All fields are required.');
|
||||
return;
|
||||
}
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(linkedinUrl);
|
||||
} catch (_) {
|
||||
setError('Please enter a valid LinkedIn URL.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
onSubmit({ firstName, lastName, linkedinUrl });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && <div className="p-3 bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 rounded-md text-sm">{error}</div>}
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
First Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="linkedinUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
LinkedIn Profile URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
id="linkedinUrl"
|
||||
value={linkedinUrl}
|
||||
onChange={(e) => setLinkedinUrl(e.target.value)}
|
||||
placeholder="https://www.linkedin.com/in/..."
|
||||
className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white dark:bg-gray-600 dark:text-gray-200 border border-gray-300 dark:border-gray-500 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPersonForm;
|
||||
27
frontend/components/Icons.tsx
Normal file
27
frontend/components/Icons.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const PlusIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LinkedInIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.25 6.5 1.75 1.75 0 016.5 8.25zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93-.94 0-1.62.61-1.62 1.93V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.38.96 3.38 3.3 V19z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-4.684v.005c.317.055.635.101.954.142m-1.58-2.318a2.25 2.25 0 00-3.814-1.625A2.25 2.25 0 006 13.5a2.25 2.25 0 00-1.58 2.318m1.58-2.318l-.003.004a2.25 2.25 0 01-3.814-1.625a2.25 2.25 0 013.814-1.625l.003.004m0 0a2.25 2.25 0 013.814 1.625a2.25 2.25 0 01-3.814-1.625M9 13.5a2.25 2.25 0 012.25-2.25A2.25 2.25 0 0113.5 13.5a2.25 2.25 0 01-2.25 2.25A2.25 2.25 0 019 13.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
62
frontend/components/Modal.tsx
Normal file
62
frontend/components/Modal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { CloseIcon } from './Icons';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start w-full">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
|
||||
<CloseIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
57
frontend/components/PersonList.tsx
Normal file
57
frontend/components/PersonList.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import React from 'react';
|
||||
import type { Person } from '../types';
|
||||
import { LinkedInIcon } from './Icons';
|
||||
|
||||
interface PersonListProps {
|
||||
people: Person[];
|
||||
}
|
||||
|
||||
const PersonList: React.FC<PersonListProps> = ({ people }) => {
|
||||
return (
|
||||
<div className="overflow-hidden bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
First Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Last Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
LinkedIn Profile
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{people.map((person) => (
|
||||
<tr key={person.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{person.firstName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{person.lastName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
<a
|
||||
href={person.linkedinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium group"
|
||||
>
|
||||
<LinkedInIcon className="w-5 h-5 mr-2 text-gray-400 group-hover:text-indigo-500 dark:group-hover:text-indigo-400" />
|
||||
View Profile
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonList;
|
||||
24
frontend/index.html
Normal file
24
frontend/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profile Linker</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-900">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/index.tsx
Normal file
16
frontend/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
frontend/metadata.json
Normal file
5
frontend/metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Profile Linker",
|
||||
"description": "An application to manage a list of contacts with their first name, last name, and LinkedIn profile URL. It features the ability to add new contacts through a mocked backend service, designed for easy integration with a real API.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "ResumeFormatter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
52
frontend/services/apiService.ts
Normal file
52
frontend/services/apiService.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Person, NewPerson } from '../types';
|
||||
|
||||
// Get APP_NAME from environment or use default
|
||||
const APP_NAME = import.meta.env.VITE_APP_NAME || 'ResumeFormatter';
|
||||
const API_BASE_URL = `/${APP_NAME}/api`;
|
||||
|
||||
// Function to get all people
|
||||
export const getPeople = async (): Promise<Person[]> => {
|
||||
console.log('Fetching people from API...');
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/people/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('... Data loaded:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching people:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to add a new person
|
||||
export const addPerson = async (newPerson: NewPerson): Promise<Person> => {
|
||||
console.log('Adding person to API:', newPerson);
|
||||
|
||||
if (!newPerson.firstName || !newPerson.lastName || !newPerson.linkedinUrl) {
|
||||
throw new Error('All fields are required.');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/people/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newPerson),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('... Person added:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error adding person:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
29
frontend/tsconfig.json
Normal file
29
frontend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
9
frontend/types.ts
Normal file
9
frontend/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
export interface Person {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
linkedinUrl: string;
|
||||
}
|
||||
|
||||
export type NewPerson = Omit<Person, 'id'>;
|
||||
31
frontend/vite.config.ts
Normal file
31
frontend/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
const appName = env.VITE_APP_NAME || 'ResumeFormatter';
|
||||
|
||||
return {
|
||||
base: `/${appName}/`,
|
||||
build: {
|
||||
assetsDir: 'assets',
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user