This commit is contained in:
jpmvaz
2026-09-13 20:18:51 +01:00
commit cd3d960852
9 changed files with 774 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.DS_Store
backup_*.json
Binary file not shown.
+183
View File
@@ -0,0 +1,183 @@
# Martinhal Contact Management System - Docker Setup
## 🐳 Docker Installation (Recommended)
### Prerequisites
- Docker installed: https://docs.docker.com/get-docker/
- Docker Compose installed (usually comes with Docker Desktop)
### Quick Start with Docker
1. **Make sure you have all files in the same directory:**
```
martinhal-contacts/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── index.html
├── credentials.json
├── server.js
├── package.json
└── contacts_data.json (will be created automatically)
```
2. **Build and start the container:**
```bash
docker-compose up -d
```
3. **Access the application:**
Open your browser and go to: `http://localhost:8000`
4. **Stop the application:**
```bash
docker-compose down
```
5. **View logs:**
```bash
docker-compose logs -f
```
### Docker Commands Reference
**Start the application:**
```bash
docker-compose up -d
```
**Stop the application:**
```bash
docker-compose down
```
**Restart the application:**
```bash
docker-compose restart
```
**View logs:**
```bash
docker-compose logs -f martinhal-contacts
```
**Rebuild after code changes:**
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
**Access container shell:**
```bash
docker exec -it martinhal-contacts sh
```
## 📦 Data Persistence
Docker volumes ensure your data persists:
- **contacts_data.json** - Your contact database (automatically backed up)
- **backups/** - Directory for backup files
Even if you delete and recreate containers, your data remains safe!
## 🔄 Updating the Application
1. Update your files (index.html, server.js, etc.)
2. Rebuild and restart:
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
## 🔐 Current Passwords
- **Frontend Password**: `ZaAhdf4h8k79598pHD5H3`
- **Backend Username**: `admin`
- **Backend Password**: `yt33PdH9WgAGR5z4SFDf6`
## 🛠 Troubleshooting
**Port 8000 already in use:**
Edit `docker-compose.yml` and change the port mapping:
```yaml
ports:
- "3000:8000" # Access via http://localhost:3000
```
**Container won't start:**
```bash
docker-compose logs martinhal-contacts
```
**Remove everything and start fresh:**
```bash
docker-compose down
docker system prune -a
docker-compose up -d
```
## 📋 Alternative: Manual Setup (No Docker)
If you don't want to use Docker:
### Step 1: Install Node.js
Download from: https://nodejs.org/
### Step 2: Install Dependencies
```bash
npm install
```
### Step 3: Start Server
```bash
npm start
```
### Step 4: Open Browser
```
http://localhost:8000
```
## 🎯 Production Deployment
For production deployment with Docker:
1. **Use environment variables for sensitive data**
2. **Set up HTTPS with reverse proxy (nginx)**
3. **Configure automated backups**
4. **Set resource limits in docker-compose.yml**
Example production docker-compose.yml:
```yaml
version: '3.8'
services:
martinhal-contacts:
build: .
container_name: martinhal-contacts
ports:
- "8000:8000"
volumes:
- ./contacts_data.json:/app/contacts_data.json
- ./backups:/app/backups
restart: always
environment:
- NODE_ENV=production
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
```
## ✅ Benefits of Docker
-**Consistent environment** - Works the same everywhere
-**Easy deployment** - One command to start
-**Isolated** - Doesn't interfere with other apps
-**Easy updates** - Rebuild and restart
-**Data persistence** - Your data is safe
-**Easy backups** - Just copy the volume
That's it! Your Martinhal Contact Management System is now running in Docker! 🎉
+10
View File
@@ -0,0 +1,10 @@
{
"salt": "Mh7pQ2xR9nF8sT4vY3",
"frontend": {
"password": "Fwl2GDVUTDoBBXEBRm0MBhF3eCAE"
},
"backend": {
"username": "LAxaGT8=",
"password": "NBwEQwFWMGtuCQd/IWFOQgp1CQ4B"
}
}
+16
View File
@@ -0,0 +1,16 @@
version: '3.8'
services:
martinhal-contacts:
build: .
container_name: martinhal-contacts
ports:
- "8000:8000"
volumes:
- contacts-data:/app/data
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
contacts-data:
+24
View File
@@ -0,0 +1,24 @@
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy application files
COPY index.html ./
COPY credentials.json ./
COPY server.js ./
# Create backups directory
RUN mkdir -p /app/backups
# Expose port
EXPOSE 8000
# Start the server
CMD ["npm", "start"]
+425
View File
@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Martinhal Contacts</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const{useState,useEffect}=React;
const encryptData=t=>btoa(t);
const decryptData=t=>{try{return atob(t)}catch{return null}};
const xorDecrypt=(s,k)=>{const d=atob(s);let r='';for(let i=0;i<d.length;i++)r+=String.fromCharCode(d.charCodeAt(i)^k.charCodeAt(i%k.length));return r};
function App(){
const[view,setView]=useState('front');
const[dark,setDark]=useState(false);
const[contacts,setContacts]=useState([]);
const[depts,setDepts]=useState([]);
const[locs,setLocs]=useState([]);
const[search,setSearch]=useState('');
const[filterLoc,setFilterLoc]=useState('All');
const[filterDept,setFilterDept]=useState('All');
const[auth,setAuth]=useState(false);
const[unlocked,setUnlocked]=useState(false);
const[frontPwd,setFrontPwd]=useState('');
const[user,setUser]=useState('');
const[pwd,setPwd]=useState('');
const[users,setUsers]=useState({});
const[editing,setEditing]=useState(null);
const[editDept,setEditDept]=useState(null);
const[editLoc,setEditLoc]=useState(null);
const[newDept,setNewDept]=useState('');
const[newLoc,setNewLoc]=useState('');
const[form,setForm]=useState({name:'',unit:'',phone:'',mobile:'',email:'',department:'',locations:[],photo:''});
const[loaded,setLoaded]=useState(false);
const[creds,setCreds]=useState(null);
useEffect(()=>{
fetch('./credentials.json')
.then(r=>{if(!r.ok)throw new Error('Not found');return r.json()})
.then(d=>{
setCreds(d);
setLoaded(true);
const u=xorDecrypt(d.backend.username,d.salt);
const p=xorDecrypt(d.backend.password,d.salt);
setUsers({[u]:{password:encryptData(p)}});
loadData();
})
.catch(e=>{console.error(e);alert('Error loading credentials')});
},[]);
const loadData=async()=>{
try{
const r=await fetch('/api/data');
if(r.ok){
const d=await r.json();
setContacts(d.contacts||[]);
setDepts(d.departments||['IT','Accounting','Board','Housekeeping','Maintenance']);
setLocs(d.locations||['Martinhal Oriente','Martinhal Lisbon','Martinhal Quinta','Martinhal Sagres']);
}
}catch(e){console.error(e)}
};
const saveData=async()=>{
try{
await fetch('/api/data',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({contacts,departments:depts,locations:locs})
});
}catch(e){console.error(e)}
};
useEffect(()=>{
if(loaded&&contacts.length>=0){
saveData();
}
},[contacts,depts,locs]);
const checkFront=p=>{
if(!creds)return false;
return p===xorDecrypt(creds.frontend.password,creds.salt);
};
const doFrontLogin=()=>{
if(checkFront(frontPwd)){setUnlocked(true);setFrontPwd('')}
else{alert('Invalid password');setFrontPwd('')}
};
const doLogin=()=>{
if(users[user]){
if(decryptData(users[user].password)===pwd){setAuth(true);setPwd('')}
else alert('Invalid credentials')
}else alert('User not found')
};
const uploadPhoto=(e,isEdit)=>{
const f=e.target.files[0];
if(f){
const r=new FileReader();
r.onloadend=()=>{
if(isEdit)setEditing({...editing,photo:r.result});
else setForm({...form,photo:r.result});
};
r.readAsDataURL(f);
}
};
const addContact=()=>{if(form.name&&form.email){setContacts([...contacts,{...form,id:Date.now()}]);setForm({name:'',unit:'',phone:'',mobile:'',email:'',department:'',locations:[],photo:''})}};
const updateContact=()=>{setContacts(contacts.map(c=>c.id===editing.id?editing:c));setEditing(null)};
const delContact=id=>{if(confirm('Delete contact?'))setContacts(contacts.filter(c=>c.id!==id))};
const addDept=()=>{if(newDept&&!depts.includes(newDept)){setDepts([...depts,newDept]);setNewDept('')}};
const addLoc=()=>{if(newLoc&&!locs.includes(newLoc)){setLocs([...locs,newLoc]);setNewLoc('')}};
const updateDept=old=>{if(editDept&&editDept!==old){setDepts(depts.map(d=>d===old?editDept:d));setContacts(contacts.map(c=>({...c,department:c.department===old?editDept:c.department})))}setEditDept(null)};
const updateLoc=old=>{if(editLoc&&editLoc!==old){setLocs(locs.map(l=>l===old?editLoc:l));setContacts(contacts.map(c=>({...c,locations:c.locations.map(l=>l===old?editLoc:l)})))}setEditLoc(null)};
const delDept=d=>{if(confirm(`Delete ${d}?`)){setDepts(depts.filter(x=>x!==d));setContacts(contacts.map(c=>({...c,department:c.department===d?'':c.department})))}};
const delLoc=l=>{if(confirm(`Delete ${l}?`)){setLocs(locs.filter(x=>x!==l));setContacts(contacts.map(c=>({...c,locations:c.locations.filter(x=>x!==l)})))}};
const exportXLS=()=>{
const d=[['Name','Unit','Phone','Mobile','Email','Department','Locations']];
contacts.forEach(c=>d.push([c.name,c.unit,c.phone,c.mobile,c.email,c.department,c.locations.join(', ')]));
const wb=XLSX.utils.book_new();
const ws=XLSX.utils.aoa_to_sheet(d);
ws['!cols']=[{wch:20},{wch:15},{wch:18},{wch:18},{wch:30},{wch:15},{wch:40}];
XLSX.utils.book_append_sheet(wb,ws,"Contacts");
XLSX.writeFile(wb,`Contacts_${new Date().toISOString().split('T')[0]}.xlsx`);
};
const exportPDF=()=>{
const{jsPDF}=window.jspdf;
const doc=new jsPDF('l','mm','a4');
doc.setFontSize(18);
doc.text('Martinhal Contacts',14,15);
doc.setFontSize(10);
doc.text(`Exported: ${new Date().toLocaleDateString()}`,14,22);
const d=contacts.map(c=>[c.name,c.unit,c.phone,c.mobile,c.email,c.department,c.locations.join(', ')]);
doc.autoTable({
head:[['Name','Unit','Phone','Mobile','Email','Department','Locations']],
body:d,
startY:28,
styles:{fontSize:8,cellPadding:2},
headStyles:{fillColor:[102,126,234]},
columnStyles:{0:{cellWidth:35},1:{cellWidth:25},2:{cellWidth:30},3:{cellWidth:30},4:{cellWidth:50},5:{cellWidth:25},6:{cellWidth:'auto'}}
});
doc.save(`Contacts_${new Date().toISOString().split('T')[0]}.pdf`);
};
const downloadTemplate=()=>{
const data=[
['Name','Unit','Phone','Mobile','Email','Department','Locations'],
['John Doe','Marketing','+351 123 456 789','+351 987 654 321','john.doe@example.com','IT','Martinhal Oriente'],
['Jane Smith','Finance','+351 123 456 790','+351 987 654 322','jane.smith@example.com','Accounting','Martinhal Lisbon, Martinhal Quinta']
];
const wb=XLSX.utils.book_new();
const ws=XLSX.utils.aoa_to_sheet(data);
ws['!cols']=[{wch:20},{wch:15},{wch:18},{wch:18},{wch:30},{wch:15},{wch:40}];
XLSX.utils.book_append_sheet(wb,ws,"Template");
XLSX.writeFile(wb,'Martinhal_Contacts_Template.xlsx');
};
const importFromExcel=e=>{
const file=e.target.files[0];
if(!file)return;
const reader=new FileReader();
reader.onload=evt=>{
try{
const data=new Uint8Array(evt.target.result);
const workbook=XLSX.read(data,{type:'array'});
const sheet=workbook.Sheets[workbook.SheetNames[0]];
const rows=XLSX.utils.sheet_to_json(sheet,{header:1});
let imported=0;
let errors=0;
for(let i=1;i<rows.length;i++){
const row=rows[i];
if(!row[0]||!row[4])continue;
const newContact={
id:Date.now()+i,
name:row[0]||'',
unit:row[1]||'',
phone:row[2]||'',
mobile:row[3]||'',
email:row[4]||'',
department:row[5]||'',
locations:row[6]?row[6].split(',').map(l=>l.trim()).filter(l=>locs.includes(l)):[],
photo:''
};
const exists=contacts.find(c=>c.email.toLowerCase()===newContact.email.toLowerCase());
if(!exists){
setContacts(prev=>[...prev,newContact]);
imported++;
}else{
errors++;
}
}
alert(`Import complete!\nImported: ${imported} contacts\nSkipped (duplicates): ${errors}`);
}catch(err){
alert('Error reading file. Please use the template format.');
console.error(err);
}
};
reader.readAsArrayBuffer(file);
e.target.value='';
};
const filtered=contacts.filter(c=>{
const s=(c.name+c.email+c.unit).toLowerCase().includes(search.toLowerCase());
const l=filterLoc==='All'||c.locations.includes(filterLoc);
const d=filterDept==='All'||c.department===filterDept;
return s&&l&&d;
});
if(!loaded)return<div className="min-h-screen bg-purple-50 flex items-center justify-center"><div className="animate-spin rounded-full h-16 w-16 border-b-4 border-purple-600"/></div>;
if(view==='front'){
if(!unlocked)return(
<div className="min-h-screen bg-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<h2 className="text-3xl font-bold text-purple-600 mb-2 text-center">Martinhal Contacts</h2>
<p className="text-gray-600 mb-6 text-center">Enter password</p>
<input type="password" value={frontPwd} onChange={e=>setFrontPwd(e.target.value)} onKeyPress={e=>e.key==='Enter'&&doFrontLogin()} className="w-full px-4 py-3 border-2 rounded-xl mb-4 focus:ring-2 focus:ring-purple-500" placeholder="Password"/>
<button onClick={doFrontLogin} className="w-full bg-purple-600 text-white py-3 rounded-xl hover:bg-purple-700 font-semibold">Access</button>
</div>
</div>
);
return(
<div className={dark?'min-h-screen bg-gray-900 text-white':'min-h-screen bg-purple-50'}>
<div className={dark?'bg-gray-800 p-6 shadow-lg':'bg-purple-600 text-white p-6 shadow-lg'}>
<div className="max-w-7xl mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold">Martinhal Contacts</h1>
<div className="flex gap-3">
<button onClick={()=>setDark(!dark)} className="px-4 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">{dark?'☀️':'🌙'}</button>
<button onClick={()=>setView('admin')} className="px-4 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">Admin</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto p-6">
<div className={dark?'bg-gray-800 p-6 rounded-xl mb-6':'bg-white p-6 rounded-xl shadow-lg mb-6'}>
<div className="flex flex-col gap-4">
<div className="flex gap-4 flex-wrap">
<input type="text" placeholder="Search..." value={search} onChange={e=>setSearch(e.target.value)} className={dark?'flex-1 min-w-[200px] px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 min-w-[200px] px-4 py-3 border-2 rounded-lg'}/>
<select value={filterDept} onChange={e=>setFilterDept(e.target.value)} className={dark?'px-6 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white font-medium':'px-6 py-3 border-2 rounded-lg font-medium'}>
<option value="All">All Departments</option>
{depts.map(d=><option key={d} value={d}>{d}</option>)}
</select>
<select value={filterLoc} onChange={e=>setFilterLoc(e.target.value)} className={dark?'px-6 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white font-medium':'px-6 py-3 border-2 rounded-lg font-medium'}>
<option value="All">All Locations</option>
{locs.map(l=><option key={l} value={l}>{l}</option>)}
</select>
</div>
{(filterLoc!=='All'||filterDept!=='All')&&<div className="flex items-center gap-2 flex-wrap"><span className={dark?'text-gray-300':'text-gray-600'}>Active filters:</span>{filterDept!=='All'&&<span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium flex items-center gap-2">{filterDept}<button onClick={()=>setFilterDept('All')} className="hover:text-red-200"></button></span>}{filterLoc!=='All'&&<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm font-medium flex items-center gap-2">{filterLoc}<button onClick={()=>setFilterLoc('All')} className="hover:text-red-200"></button></span>}<button onClick={()=>{setFilterLoc('All');setFilterDept('All')}} className="text-red-500 hover:text-red-700 font-bold ml-2">Clear All</button></div>}
</div>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map(c=>(
<div key={c.id} className={dark?'bg-gray-800 rounded-xl shadow-lg overflow-hidden':'bg-white rounded-xl shadow-lg overflow-hidden'}>
<div className="bg-purple-600 p-6 text-center text-white">
{c.photo?<img src={c.photo} alt={c.name} className="w-24 h-24 rounded-full mx-auto border-4 border-white object-cover"/>:<div className="w-24 h-24 rounded-full bg-white bg-opacity-20 mx-auto border-4 border-white flex items-center justify-center text-4xl">👤</div>}
<h3 className="text-xl font-bold mt-4">{c.name}</h3>
<p className="text-purple-100 text-sm">{c.unit}</p>
</div>
<div className="p-6 space-y-2">
<p className={dark?'text-gray-300':''}><strong className="text-purple-600">Phone:</strong> {c.phone}</p>
<p className={dark?'text-gray-300':''}><strong className="text-purple-600">Mobile:</strong> {c.mobile}</p>
<p className={dark?'text-gray-300':'break-all'}><strong className="text-purple-600">Email:</strong> {c.email}</p>
<div className="pt-2 border-t">
<span className="inline-block bg-purple-500 text-white px-3 py-1 rounded-full text-xs mb-2">{c.department}</span>
<div className="flex flex-wrap gap-1">
{c.locations.map(l=><span key={l} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">{l}</span>)}
</div>
</div>
</div>
</div>
))}
</div>
{filtered.length===0&&<div className={dark?'bg-gray-800 p-12 rounded-xl text-center text-gray-400':'bg-white p-12 rounded-xl shadow-lg text-center text-gray-500'}>No contacts found</div>}
</div>
<footer className={dark?'text-center py-6 text-gray-400':'text-center py-6 text-gray-600'}>© Joao Vaz 2026</footer>
</div>
);
}
if(!auth)return(
<div className="min-h-screen bg-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-purple-600">Admin Login</h2>
<button onClick={()=>setView('front')} className="text-gray-500 hover:text-gray-700"></button>
</div>
<input type="text" placeholder="Username" value={user} onChange={e=>setUser(e.target.value)} className="w-full px-4 py-3 border-2 rounded-xl mb-3"/>
<input type="password" placeholder="Password" value={pwd} onChange={e=>setPwd(e.target.value)} onKeyPress={e=>e.key==='Enter'&&doLogin()} className="w-full px-4 py-3 border-2 rounded-xl mb-4"/>
<button onClick={doLogin} className="w-full bg-purple-600 text-white py-3 rounded-xl hover:bg-purple-700 font-semibold">Login</button>
</div>
</div>
);
return(
<div className={dark?'min-h-screen bg-gray-900 text-white':'min-h-screen bg-gray-50'}>
<div className="bg-green-600 text-white p-6 shadow-lg">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold">Admin Panel</h1>
<div className="flex gap-3">
<label className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800 cursor-pointer">
📥 Import Excel
<input type="file" accept=".xlsx,.xls" onChange={importFromExcel} className="hidden"/>
</label>
<button onClick={downloadTemplate} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📄 Template</button>
<button onClick={exportXLS} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📊 Export Excel</button>
<button onClick={exportPDF} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📄 PDF</button>
<button onClick={()=>setDark(!dark)} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">{dark?'☀️':'🌙'}</button>
<button onClick={()=>setView('front')} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">View</button>
<button onClick={()=>{setAuth(false);setView('front')}} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">Logout</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto p-6">
<div className={dark?'bg-gray-800 p-6 rounded-lg mb-6':'bg-white p-6 rounded-lg shadow-lg mb-6'}>
<h2 className="text-xl font-bold mb-4">Add Contact</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="col-span-2 flex items-center gap-4">
{form.photo?<img src={form.photo} className="w-20 h-20 rounded-full object-cover"/>:<div className="w-20 h-20 rounded-full bg-gray-200 flex items-center justify-center text-3xl">👤</div>}
<label className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer">Upload Photo<input type="file" accept="image/*" onChange={e=>uploadPhoto(e,false)} className="hidden"/></label>
</div>
<input type="text" placeholder="Name *" value={form.name} onChange={e=>setForm({...form,name:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Unit" value={form.unit} onChange={e=>setForm({...form,unit:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Phone" value={form.phone} onChange={e=>setForm({...form,phone:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Mobile" value={form.mobile} onChange={e=>setForm({...form,mobile:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="email" placeholder="Email *" value={form.email} onChange={e=>setForm({...form,email:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<select value={form.department} onChange={e=>setForm({...form,department:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}>
<option value="">Select Department</option>
{depts.map(d=><option key={d} value={d}>{d}</option>)}
</select>
<div className="col-span-2 flex gap-2 flex-wrap">
{locs.map(l=><label key={l} className="flex items-center gap-1"><input type="checkbox" checked={form.locations.includes(l)} onChange={e=>setForm({...form,locations:e.target.checked?[...form.locations,l]:form.locations.filter(x=>x!==l)})}/>{l}</label>)}
</div>
</div>
<button onClick={addContact} className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add Contact</button>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-6">
<div className={dark?'bg-gray-800 p-6 rounded-lg':'bg-white p-6 rounded-lg shadow-lg'}>
<h2 className="text-xl font-bold mb-4">Departments</h2>
<div className="flex gap-2 mb-4">
<input type="text" placeholder="New department" value={newDept} onChange={e=>setNewDept(e.target.value)} className={dark?'flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 px-4 py-2 border rounded-lg'}/>
<button onClick={addDept} className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add</button>
</div>
<div className="flex flex-wrap gap-2">
{depts.map(d=><div key={d} className="flex items-center gap-2 bg-purple-100 px-3 py-2 rounded-lg">{editDept===d?<><input type="text" value={editDept} onChange={e=>setEditDept(e.target.value)} className="px-2 py-1 border rounded"/><button onClick={()=>updateDept(d)} className="text-green-600"></button><button onClick={()=>setEditDept(null)} className="text-gray-600"></button></>:<><span className="text-purple-800">{d}</span><button onClick={()=>setEditDept(d)} className="text-blue-600"></button><button onClick={()=>delDept(d)} className="text-red-600">🗑</button></>}</div>)}
</div>
</div>
<div className={dark?'bg-gray-800 p-6 rounded-lg':'bg-white p-6 rounded-lg shadow-lg'}>
<h2 className="text-xl font-bold mb-4">Locations</h2>
<div className="flex gap-2 mb-4">
<input type="text" placeholder="New location" value={newLoc} onChange={e=>setNewLoc(e.target.value)} className={dark?'flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 px-4 py-2 border rounded-lg'}/>
<button onClick={addLoc} className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add</button>
</div>
<div className="flex flex-wrap gap-2">
{locs.map(l=><div key={l} className="flex items-center gap-2 bg-blue-100 px-3 py-2 rounded-lg">{editLoc===l?<><input type="text" value={editLoc} onChange={e=>setEditLoc(e.target.value)} className="px-2 py-1 border rounded"/><button onClick={()=>updateLoc(l)} className="text-green-600"></button><button onClick={()=>setEditLoc(null)} className="text-gray-600"></button></>:<><span className="text-blue-800">{l}</span><button onClick={()=>setEditLoc(l)} className="text-blue-600"></button><button onClick={()=>delLoc(l)} className="text-red-600">🗑</button></>}</div>)}
</div>
</div>
</div>
<div className={dark?'bg-gray-800 rounded-lg overflow-hidden':'bg-white rounded-lg shadow-lg overflow-hidden'}>
<h2 className="text-xl font-bold p-6 border-b">Contacts</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className={dark?'bg-gray-700':'bg-gray-100'}>
<tr>
<th className="text-left p-4">Photo</th>
<th className="text-left p-4">Name</th>
<th className="text-left p-4">Unit</th>
<th className="text-left p-4">Phone</th>
<th className="text-left p-4">Mobile</th>
<th className="text-left p-4">Email</th>
<th className="text-left p-4">Dept</th>
<th className="text-left p-4">Locations</th>
<th className="text-left p-4">Actions</th>
</tr>
</thead>
<tbody>
{contacts.map(c=><tr key={c.id} className="border-b">{editing?.id===c.id?
<><td className="p-4"><div className="flex items-center gap-2">{editing.photo?<img src={editing.photo} className="w-12 h-12 rounded-full object-cover"/>:<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">👤</div>}<label className="text-blue-600 cursor-pointer">📤<input type="file" accept="image/*" onChange={e=>uploadPhoto(e,true)} className="hidden"/></label></div></td>
<td className="p-4"><input type="text" value={editing.name} onChange={e=>setEditing({...editing,name:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.unit} onChange={e=>setEditing({...editing,unit:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.phone} onChange={e=>setEditing({...editing,phone:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.mobile} onChange={e=>setEditing({...editing,mobile:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="email" value={editing.email} onChange={e=>setEditing({...editing,email:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><select value={editing.department} onChange={e=>setEditing({...editing,department:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}><option value="">Select</option>{depts.map(d=><option key={d} value={d}>{d}</option>)}</select></td>
<td className="p-4"><div className="flex gap-1 flex-wrap">{locs.map(l=><label key={l} className="flex items-center gap-1 text-xs"><input type="checkbox" checked={editing.locations.includes(l)} onChange={e=>setEditing({...editing,locations:e.target.checked?[...editing.locations,l]:editing.locations.filter(x=>x!==l)})}/>{l}</label>)}</div></td>
<td className="p-4"><div className="flex gap-2"><button onClick={updateContact} className="text-green-600 text-xl">✓</button><button onClick={()=>setEditing(null)} className="text-gray-600 text-xl">✕</button></div></td></>
:
<><td className="p-4">{c.photo?<img src={c.photo} className="w-12 h-12 rounded-full object-cover"/>:<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">👤</div>}</td>
<td className="p-4 font-medium">{c.name}</td>
<td className="p-4">{c.unit}</td>
<td className="p-4">{c.phone}</td>
<td className="p-4">{c.mobile}</td>
<td className="p-4">{c.email}</td>
<td className="p-4"><span className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">{c.department}</span></td>
<td className="p-4"><div className="flex flex-wrap gap-1">{c.locations.map(l=><span key={l} className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">{l}</span>)}</div></td>
<td className="p-4"><div className="flex gap-2"><button onClick={()=>setEditing({...c})} className="text-blue-600 hover:text-blue-800 text-xl">✎</button><button onClick={()=>delContact(c.id)} className="text-red-600 hover:text-red-800 text-xl">🗑</button></div></td></>
}</tr>)}
</tbody>
</table>
</div>
</div>
</div>
<footer className={dark?'text-center py-6 text-gray-400 bg-gray-900':'text-center py-6 text-gray-600'}>© Joao Vaz 2026</footer>
</div>
);
}
ReactDOM.render(<App/>,document.getElementById('root'));
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
{
"name": "martinhal-contacts",
"version": "1.0.0",
"description": "Martinhal Contact Management System",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
}
}
+95
View File
@@ -0,0 +1,95 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const app = express();
const PORT = 8000;
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' })); // Increased limit for base64 images
app.use(express.static(__dirname)); // Serve static files from current directory
const DATA_FILE = path.join(__dirname, 'contacts_data.json');
const CREDENTIALS_FILE = path.join(__dirname, 'credentials.json');
// Initialize data file if it doesn't exist
if (!fs.existsSync(DATA_FILE)) {
const initialData = {
contacts: [
{ id: 1, name: 'John Smith', unit: 'Marketing', phone: '+351 123 456 789', mobile: '+351 987 654 321', email: 'john.smith@company.com', department: 'IT', locations: ['Martinhal Oriente'], photo: 'https://i.pravatar.cc/150?img=12' },
{ id: 2, name: 'Maria Santos', unit: 'Finance', phone: '+351 123 456 790', mobile: '+351 987 654 322', email: 'maria.santos@company.com', department: 'Accounting', locations: ['Martinhal Lisbon', 'Martinhal Quinta'], photo: 'https://i.pravatar.cc/150?img=5' },
{ id: 3, name: 'Pedro Costa', unit: 'Operations', phone: '+351 123 456 791', mobile: '+351 987 654 323', email: 'pedro.costa@company.com', department: 'Maintenance', locations: ['Martinhal Sagres'], photo: 'https://i.pravatar.cc/150?img=33' }
],
departments: ['IT', 'Accounting', 'Board', 'Housekeeping', 'Maintenance'],
locations: ['Martinhal Oriente', 'Martinhal Lisbon', 'Martinhal Quinta', 'Martinhal Sagres']
};
fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2));
console.log('Created initial data file:', DATA_FILE);
}
// Check if credentials.json exists
if (!fs.existsSync(CREDENTIALS_FILE)) {
console.error('WARNING: credentials.json not found!');
console.error('Please make sure credentials.json is in the same directory as server.js');
}
// Get credentials (for debugging)
app.get('/api/credentials', (req, res) => {
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return res.status(404).json({ error: 'credentials.json not found' });
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
res.json(credentials);
} catch (error) {
console.error('Error reading credentials:', error);
res.status(500).json({ error: 'Failed to read credentials' });
}
});
// Get all data
app.get('/api/data', (req, res) => {
try {
const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
res.json(data);
} catch (error) {
console.error('Error reading data:', error);
res.status(500).json({ error: 'Failed to read data' });
}
});
// Save all data
app.post('/api/data', (req, res) => {
try {
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
res.json({ success: true });
} catch (error) {
console.error('Error saving data:', error);
res.status(500).json({ error: 'Failed to save data' });
}
});
// Backup endpoint (optional - creates timestamped backup)
app.post('/api/backup', (req, res) => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(__dirname, `backup_${timestamp}.json`);
const data = fs.readFileSync(DATA_FILE, 'utf8');
fs.writeFileSync(backupFile, data);
res.json({ success: true, file: backupFile });
} catch (error) {
console.error('Error creating backup:', error);
res.status(500).json({ error: 'Failed to create backup' });
}
});
app.listen(PORT, () => {
console.log('=================================');
console.log(`Server running at http://localhost:${PORT}`);
console.log(`Data file: ${DATA_FILE}`);
console.log(`Credentials file: ${CREDENTIALS_FILE}`);
console.log(`Credentials exists: ${fs.existsSync(CREDENTIALS_FILE)}`);
console.log('=================================');
});