Move multi-role pages out of team/ to pages/ root

CompanyDetail (team+client), BrandBook, SurveyMaker, Converters (team+external)
all serve more than one role — belong at pages/ not pages/team/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-19 22:14:49 -04:00
parent 66baa2869e
commit ff159c5937
5 changed files with 23 additions and 23 deletions
File diff suppressed because it is too large Load Diff
-534
View File
@@ -1,534 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { serviceTypes } from '../../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
import { renameClientFolder, backfillClientFolders } from '../../lib/filebrowserFolders';
export default function CompanyDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isTeam = currentUser?.role === 'team';
const [company, setCompany] = useState(null);
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]);
const [availableUsers, setAvailableUsers] = useState([]);
const [prices, setPrices] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null);
const [assigning, setAssigning] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [savingProject, setSavingProject] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameVal, setNameVal] = useState('');
const [savingName, setSavingName] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
async function load() {
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', id).single(),
supabase.from('projects').select('*').eq('company_id', id).order('created_at', { ascending: false }),
supabase.from('company_prices').select('*').eq('company_id', id),
supabase.from('company_members').select('profile_id, created_at, profile:profiles(id, name, email, created_at, role, company_id)').eq('company_id', id),
supabase.from('profiles').select('id, name, email, created_at, role, company_id').eq('role', 'client').order('name'),
supabase.from('tasks').select('*, project:projects!inner(company_id)').eq('project.company_id', id),
]);
const assignedMap = new Map();
(memberRows || []).forEach(row => {
if (row.profile?.role === 'client') assignedMap.set(row.profile.id, { ...row.profile, membership_created_at: row.created_at });
});
(allUsers || []).filter(user => user.company_id === id).forEach(user => {
if (!assignedMap.has(user.id)) assignedMap.set(user.id, user);
});
const assignedUsers = [...assignedMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const assignedIds = new Set(assignedUsers.map(user => user.id));
setCompany(co);
setProjects(p || []);
setPrices(pr || []);
setUsers(assignedUsers);
setAvailableUsers((allUsers || []).filter(user => !assignedIds.has(user.id)));
setTasks(t || []);
setLoading(false);
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCompanyNameSave = async (e) => {
e.preventDefault();
if (!nameVal.trim()) return;
setSavingName(true);
const oldName = company.name;
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
backfillClientFolders().catch(() => {});
setCompany(c => ({ ...c, name: nameVal.trim() }));
setEditingName(false);
setSavingName(false);
};
const handleEditUserSave = async (userId) => {
if (!editUserVal.trim()) return;
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
setUsers(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
setEditingUserId(null);
};
const handleDeleteUser = async (user) => {
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account and all access. This cannot be undone.`)) return;
setDeletingUserId(user.id);
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
const errMsg = errBody?.error || data?.error || error?.message;
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
setUsers(prev => prev.filter(u => u.id !== user.id));
setAvailableUsers(prev => prev.filter(u => u.id !== user.id));
setDeletingUserId(null);
};
const handleAssignUser = async (userId) => {
setAssigning(true);
const user = availableUsers.find(u => u.id === userId);
const { error } = await supabase
.from('company_members')
.upsert({ company_id: id, profile_id: userId }, { onConflict: 'company_id,profile_id' });
if (error) {
alert('Failed to assign user. Please try again.');
setAssigning(false);
return;
}
if (user && !user.company_id) {
await supabase.from('profiles').update({ company_id: id }).eq('id', userId);
}
if (user) {
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
}
setAssigning(false);
};
const handleRemoveUser = async (userId) => {
if (!window.confirm('Remove this user from the company? They will lose access to this company data.')) return;
await supabase.from('company_members').delete().eq('company_id', id).eq('profile_id', userId);
const user = users.find(u => u.id === userId);
if (user?.company_id === id) {
const { data: nextMembership } = await supabase
.from('company_members')
.select('company_id')
.eq('profile_id', userId)
.neq('company_id', id)
.limit(1)
.maybeSingle();
await supabase.from('profiles').update({ company_id: nextMembership?.company_id || null }).eq('id', userId);
}
if (user) {
setUsers(prev => prev.filter(u => u.id !== userId));
setAvailableUsers(prev => [...prev, { ...user, company_id: user.company_id === id ? null : user.company_id }]
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
}
};
const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await deleteCompanyData(id);
navigate('/company');
};
const handleDeleteProject = async (project) => {
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
await cleanupTaskStorage(projectTaskIds);
await supabase.from('projects').delete().eq('id', project.id);
setProjects(prev => prev.filter(p => p.id !== project.id));
setTasks(prev => prev.filter(t => t.project_id !== project.id));
};
const handleCreateProject = async (e) => {
e.preventDefault();
if (!newProjectName.trim()) return;
setSavingProject(true);
const { data } = await supabase.from('projects').insert({
company_id: id,
name: newProjectName.trim(),
status: 'active',
}).select().single();
if (data) {
setProjects(prev => [data, ...prev]);
setNewProjectName('');
setShowNewProject(false);
}
setSavingProject(false);
};
const getPrice = (serviceType, priceType) =>
prices.find(p => p.service_type === serviceType && p.price_type === priceType)?.price ?? '';
const handlePriceChange = (serviceType, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.service_type === serviceType && p.price_type === priceType);
if (existing) return prev.map(p => p.service_type === serviceType && p.price_type === priceType ? { ...p, price: value } : p);
return [...prev, { service_type: serviceType, price_type: priceType, price: value, company_id: id }];
});
};
const handlePriceSave = async (serviceType) => {
setSavingPrice(serviceType);
for (const priceType of ['new', 'revision']) {
const priceVal = getPrice(serviceType, priceType);
const existing = prices.find(p => p.service_type === serviceType && p.price_type === priceType && p.id);
if (existing) {
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
} else if (priceVal !== '') {
const { data, error } = await supabase.from('company_prices').insert({
company_id: id, service_type: serviceType, price_type: priceType, price: Number(priceVal),
}).select().single();
if (error) { setSavingPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => [...prev.filter(p => !(p.service_type === serviceType && p.price_type === priceType && !p.id)), data]);
}
}
setSavingPrice(null);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!company) return <Layout><p>Company not found.</p></Layout>;
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
const completedTasks = tasks.filter(t => t.status === 'client_approved');
return (
<Layout>
<button className="back-link" onClick={() => navigate('/company')}> Back to Companies</button>
<div className="page-header">
<div>
{isTeam && editingName ? (
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="text"
value={nameVal}
onChange={e => setNameVal(e.target.value)}
autoFocus
required
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
/>
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="page-title">{company.name}</div>
{isTeam && <button className="btn-icon" title="Edit" onClick={() => { setNameVal(company.name); setEditingName(true); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>}
</div>
)}
<div className="page-subtitle">
{company.phone && <>{company.phone}</>}
{company.phone && company.address && ' · '}
{company.address && <>{company.address}</>}
{!company.phone && !company.address && 'No contact info'}
</div>
</div>
{isTeam && <button
className="btn-icon btn-icon-danger"
onClick={handleDeleteCompany}
title="Delete Company"></button>}
</div>
<div className="stats-grid" style={{ marginBottom: 28 }}>
<div className="stat-card">
<div className="stat-icon">📁</div>
<div className="stat-value">{projects.length}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{activeTasks.length}</div>
<div className="stat-label">Active Jobs</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-value">{completedTasks.length}</div>
<div className="stat-label">Completed</div>
</div>
<div className="stat-card">
<div className="stat-icon">📅</div>
<div className="stat-value" style={{ fontSize: 16 }}>{new Date(company.created_at).toLocaleDateString()}</div>
<div className="stat-label">Since</div>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`tab-btn${tab === t ? ' active' : ''}`}
style={{ textTransform: 'capitalize' }}
>
{t}
{t === 'users' && availableUsers.length > 0 && (
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
{availableUsers.length}
</span>
)}
</button>
))}
</div>
{/* Users Tab */}
{tab === 'users' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card">
<div className="card-title">Assigned Users</div>
{users.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No users assigned to this company yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{users.map((user, i) => (
<div key={user.id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 0',
borderBottom: i < users.length - 1 ? '1px solid var(--border)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1 }}>
<div style={{
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700, color: '#111', flexShrink: 0,
}}>
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</>
)}
</div>
</div>
{isTeam && editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
className="btn-icon"
title="Edit"
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button
className="btn btn-outline btn-sm"
onClick={() => handleRemoveUser(user.id)}
>Unassign</button>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? '...' : '✕'}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
{isTeam && availableUsers.length > 0 && (
<div className="card">
<div className="card-title">Available Users</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
Add an existing client user to this company. External subcontractors are assigned to projects instead.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{availableUsers.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="text"
value={editUserVal}
onChange={e => setEditUserVal(e.target.value)}
autoFocus
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
/>
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
) : (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
</>
)}
</div>
{editingUserId !== user.id && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
Assign to {company.name}
</button>
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button
className="btn-icon btn-icon-danger"
title="Delete"
onClick={() => handleDeleteUser(user)}
disabled={deletingUserId === user.id}
>{deletingUserId === user.id ? '...' : '✕'}</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Projects Tab */}
{tab === 'projects' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Project'}
</button>
</div>}
{showNewProject && (
<div className="card">
<div className="card-title">New Project</div>
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
<label>Project Name *</label>
<input
type="text"
placeholder="e.g. Brand Identity 2026"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
required
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary" disabled={savingProject || !newProjectName.trim()}>
{savingProject ? 'Creating...' : 'Create'}
</button>
</form>
</div>
)}
{projects.length === 0 ? (
<div className="empty-state">
<h3>No projects yet</h3>
<p>Create a project to start adding jobs.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => {
const projectTasks = tasks.filter(t => t.project_id === project.id);
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length;
return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
{done > 0 && <> · {done} done</>}
{' · '}Started {new Date(project.created_at).toLocaleDateString()}
</div>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
</Link>
{isTeam && <button
type="button"
onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project"
></button>}
</div>
);
})}
</div>
)}
</div>
)}
{/* Pricing Tab */}
{tab === 'pricing' && (
<div className="card">
<div className="card-title">Price List {company.name}</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
Set prices per service type for this company. These auto-fill when creating an invoice.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{serviceTypes.map(serviceType => (
<div key={serviceType} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{serviceType}</div>
{['new', 'revision'].map(priceType => (
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getPrice(serviceType, priceType)}
onChange={e => handlePriceChange(serviceType, priceType, e.target.value)}
style={{ margin: 0, width: '100%', textAlign: 'right' }}
/>
</div>
))}
<button
className="btn btn-outline btn-sm"
onClick={() => handlePriceSave(serviceType)}
disabled={savingPrice === serviceType}
>
{savingPrice === serviceType ? '...' : 'Save'}
</button>
</div>
))}
</div>
</div>
)}
</Layout>
);
}
-432
View File
@@ -1,432 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import JSZip from 'jszip';
import { heicTo, isHeic } from 'heic-to/csp';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
const INPUT_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
const OUTPUT_FORMATS = [
{ value: 'jpeg', label: 'JPG', mime: 'image/jpeg', extension: 'jpg' },
{ value: 'png', label: 'PNG', mime: 'image/png', extension: 'png' },
{ value: 'webp', label: 'WEBP', mime: 'image/webp', extension: 'webp' },
];
const HEIC_EXTENSIONS = new Set(['heic', 'heif']);
const MAX_FILES = 100;
function getExtension(name = '') {
return name.split('.').pop()?.toLowerCase() || '';
}
function isHeicFile(file) {
return HEIC_EXTENSIONS.has(getExtension(file.name)) || file.type === 'image/heic' || file.type === 'image/heif';
}
function stripExtension(name = '') {
return name.replace(/\.[^.]+$/, '') || 'converted-image';
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function loadImageFromBlob(blob) {
if ('createImageBitmap' in window) {
try {
return await createImageBitmap(blob);
} catch {
// Fallback to HTMLImageElement below.
}
}
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not decode image.'));
};
img.src = url;
});
}
async function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Conversion failed.'));
return;
}
resolve(blob);
}, type, quality);
});
}
async function convertRasterBlob(blob, format, quality) {
const image = await loadImageFromBlob(blob);
const width = image.width || image.naturalWidth;
const height = image.height || image.naturalHeight;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available in this browser.');
if (format.mime === 'image/jpeg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(image, 0, 0, width, height);
const outputBlob = await canvasToBlob(canvas, format.mime, quality);
if (typeof image.close === 'function') image.close();
return outputBlob;
}
async function convertHeicFile(file, format, quality) {
if (format.mime === 'image/jpeg' || format.mime === 'image/png') {
try {
return await heicTo({ blob: file, type: format.mime, quality });
} catch (primaryError) {
try {
return await convertRasterBlob(file, format, quality);
} catch {
throw new Error(primaryError?.message || 'HEIC format not supported.');
}
}
}
const bitmap = await heicTo({ blob: file, type: 'bitmap' });
const width = bitmap.width || bitmap.naturalWidth;
const height = bitmap.height || bitmap.naturalHeight;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available in this browser.');
if (format.mime === 'image/jpeg') {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(bitmap, 0, 0, width, height);
if (typeof bitmap.close === 'function') bitmap.close();
return canvasToBlob(canvas, format.mime, quality);
}
async function convertFile(file, format, quality) {
const looksHeic = isHeicFile(file);
const confirmedHeic = looksHeic ? await isHeic(file).catch(() => false) : false;
if (looksHeic || confirmedHeic) {
return convertHeicFile(file, format, quality);
}
return convertRasterBlob(file, format, quality);
}
export default function Converters() {
const inputRef = useRef(null);
const [files, setFiles] = useState([]);
const [outputFormat, setOutputFormat] = useState('jpeg');
const [quality, setQuality] = useState(0.92);
const [results, setResults] = useState([]);
const [converting, setConverting] = useState(false);
const [downloading, setDownloading] = useState('');
const [limitMessage, setLimitMessage] = useState('');
const selectedFormat = useMemo(
() => OUTPUT_FORMATS.find((format) => format.value === outputFormat) || OUTPUT_FORMATS[0],
[outputFormat]
);
useEffect(() => () => {
results.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
}, [results]);
const addFiles = (incoming) => {
const nextFiles = Array.from(incoming || []).filter(Boolean);
if (!nextFiles.length) return;
setFiles((current) => {
const existing = new Set(current.map(file => `${file.name}-${file.size}-${file.lastModified}`));
const deduped = nextFiles.filter(file => !existing.has(`${file.name}-${file.size}-${file.lastModified}`));
const availableSlots = Math.max(0, MAX_FILES - current.length);
const accepted = deduped.slice(0, availableSlots);
const skippedCount = deduped.length - accepted.length;
setLimitMessage(skippedCount > 0
? `Only the first ${MAX_FILES} files can be queued at one time. ${skippedCount} file${skippedCount === 1 ? '' : 's'} were skipped.`
: ''
);
return [...current, ...accepted];
});
};
const removeFile = (targetIndex) => {
setFiles((current) => current.filter((_, index) => index !== targetIndex));
};
const clearAll = () => {
setFiles([]);
setResults((current) => {
current.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
return [];
});
};
const handleConvert = async () => {
if (!files.length) return;
setConverting(true);
setResults((current) => {
current.forEach((result) => {
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
});
return files.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
name: file.name,
status: 'pending',
previewUrl: '',
blob: null,
error: '',
}));
});
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const id = `${file.name}-${file.size}-${file.lastModified}`;
setResults((current) => current.map((result) => (
result.id === id ? { ...result, status: 'converting', error: '' } : result
)));
try {
const blob = await convertFile(file, selectedFormat, quality);
const previewUrl = URL.createObjectURL(blob);
setResults((current) => current.map((result) => {
if (result.id !== id) return result;
if (result.previewUrl) URL.revokeObjectURL(result.previewUrl);
return {
...result,
status: 'done',
blob,
previewUrl,
error: '',
};
}));
} catch (error) {
setResults((current) => current.map((result) => (
result.id === id
? { ...result, status: 'error', blob: null, previewUrl: '', error: error.message || 'Conversion failed.' }
: result
)));
}
}
setConverting(false);
};
const handleDownloadAll = async () => {
const completed = results.filter(result => result.status === 'done' && result.blob);
if (!completed.length || downloading) return;
setDownloading('all');
try {
const zip = new JSZip();
completed.forEach((result) => {
zip.file(`${stripExtension(result.name)}.${selectedFormat.extension}`, result.blob);
});
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, `converted-images-${selectedFormat.extension}.zip`);
} finally {
setDownloading('');
}
};
const completedCount = results.filter(result => result.status === 'done').length;
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Image Converter</div>
<div className="page-subtitle">Convert image files in bulk, including HEIC/HEIF to JPG, PNG, or WEBP.</div>
</div>
</div>
<div style={{ display: 'grid', gap: 24 }}>
<div className="card">
<div className="card-title">Image Converter</div>
<div style={{ display: 'grid', gap: 18 }}>
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
style={{
border: '2px dashed var(--border)',
borderRadius: 10,
padding: '28px 18px',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--card-bg-2)',
color: 'var(--text-muted)',
}}
>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
Drop images here or click to upload
</div>
<div style={{ fontSize: 13 }}>
Supports JPG, PNG, WEBP, GIF, BMP, TIFF, AVIF, HEIC, and HEIF. Max {MAX_FILES} files per batch.
</div>
</div>
<input
ref={inputRef}
type="file"
accept={INPUT_ACCEPT}
multiple
style={{ display: 'none' }}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = '';
}}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 14 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Convert To</label>
<select value={outputFormat} onChange={(e) => setOutputFormat(e.target.value)}>
{OUTPUT_FORMATS.map((format) => (
<option key={format.value} value={format.value}>{format.label}</option>
))}
</select>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Quality</label>
<input
type="range"
min="0.5"
max="1"
step="0.01"
value={quality}
disabled={selectedFormat.mime === 'image/png'}
onChange={(e) => setQuality(Number(e.target.value))}
/>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
{selectedFormat.mime === 'image/png'
? 'PNG uses lossless output.'
: `${Math.round(quality * 100)}% output quality.`}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={handleConvert} disabled={!files.length || converting}>
{converting ? 'Converting...' : `Convert ${files.length ? `(${files.length})` : ''}`}
</button>
<LoadingButton className="btn btn-outline" loading={downloading === 'all'} disabled={!completedCount || Boolean(downloading)} loadingText="Preparing..." onClick={handleDownloadAll}>Download All</LoadingButton>
<button className="btn btn-outline" onClick={clearAll} disabled={!files.length && !results.length}>
Clear
</button>
</div>
{limitMessage && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{limitMessage}
</div>
)}
</div>
</div>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
<div className="card-title" style={{ margin: 0 }}>Files</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{files.length} selected · {completedCount} converted
</div>
</div>
{!files.length ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No files added yet.</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{files.map((file, index) => {
const id = `${file.name}-${file.size}-${file.lastModified}`;
const result = results.find(item => item.id === id);
const outputName = `${stripExtension(file.name)}.${selectedFormat.extension}`;
return (
<div
key={id}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
padding: 14,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 14,
alignItems: 'center',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
{file.type || 'Unknown type'} · {(file.size / 1024 / 1024).toFixed(file.size >= 1024 * 1024 ? 2 : 3)} MB
</div>
{result?.status === 'error' && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
)}
{result?.status === 'done' && (
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}>
Ready as {outputName}
</div>
)}
{result?.status === 'converting' && (
<div style={{ fontSize: 12, color: 'var(--accent)', marginTop: 8 }}>Converting...</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{result?.status === 'done' && result.blob && (
<button
className="btn btn-outline btn-sm"
onClick={() => downloadBlob(result.blob, outputName)}
>
Download
</button>
)}
<button className="btn btn-outline btn-sm" onClick={() => removeFile(index)}>
Remove
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</Layout>
);
}
-312
View File
@@ -1,312 +0,0 @@
import { useRef, useState } from 'react';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { generateSurveyMakerPdf } from '../../lib/surveyMakerPdf';
const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']);
const EMPTY_SIGN = () => ({
id: Math.random().toString(36).slice(2),
signName: '',
measurements: '',
notes: '',
mainPhoto: null,
mainPreview: '',
contextPhoto1: null,
contextPreview1: '',
contextPhoto2: null,
contextPreview2: '',
contextPhoto3: null,
contextPreview3: '',
});
function isPhotoFile(file) {
if (!file) return false;
if (file.type?.startsWith('image/')) return true;
const extension = file.name?.split('.').pop()?.toLowerCase();
return extension ? PHOTO_FILE_EXTENSIONS.has(extension) : false;
}
export default function SurveyMaker() {
const [surveyInfo, setSurveyInfo] = useState({
clientName: '',
projectName: '',
siteAddress: '',
surveyDate: new Date().toISOString().slice(0, 10),
preparedBy: '',
});
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [generating, setGenerating] = useState(false);
const [notification, setNotification] = useState(null);
const setField = (field) => (event) => {
setSurveyInfo(current => ({ ...current, [field]: event.target.value }));
};
const updateSign = (id, field, value) => {
setSigns(current => current.map(sign => (sign.id === id ? { ...sign, [field]: value } : sign)));
};
const handlePhoto = (id, field, previewField, file) => {
if (!isPhotoFile(file)) return;
updateSign(id, field, file);
updateSign(id, previewField, URL.createObjectURL(file));
};
const addSign = () => {
setSigns(current => [...current, EMPTY_SIGN()]);
};
const removeSign = (id) => {
setSigns(current => current.length === 1 ? current : current.filter(sign => sign.id !== id));
};
const handleGenerate = async () => {
setGenerating(true);
setNotification(null);
try {
await generateSurveyMakerPdf({
...surveyInfo,
signs,
});
setNotification({ type: 'success', msg: '✓ Survey PDF downloaded!' });
} catch (error) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${error.message}` });
} finally {
setGenerating(false);
}
};
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">Survey Maker</div>
<div className="page-subtitle">Create a survey PDF with sign photos, measurements, and notes.</div>
</div>
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
{notification.msg}
</div>
)}
<div style={{ display: 'grid', gap: 24 }}>
<div className="card">
<div className="card-title">Survey Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client Name</label>
<input type="text" value={surveyInfo.clientName} onChange={setField('clientName')} placeholder="e.g. Bolchoz Sign Solutions" />
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" value={surveyInfo.projectName} onChange={setField('projectName')} placeholder="e.g. Main Street Sign Survey" />
</div>
<div className="form-group">
<label>Survey Date</label>
<input type="date" value={surveyInfo.surveyDate} onChange={setField('surveyDate')} />
</div>
<div className="form-group">
<label>Prepared By</label>
<input type="text" value={surveyInfo.preparedBy} onChange={setField('preparedBy')} placeholder="Your name" />
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Site Address</label>
<input type="text" value={surveyInfo.siteAddress} onChange={setField('siteAddress')} placeholder="e.g. 123 Main St, City, State" />
</div>
</div>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div className="card-title" style={{ margin: 0 }}>Signs</div>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
<div style={{ display: 'grid', gap: 14 }}>
{signs.map((sign, index) => (
<SignCard
key={sign.id}
sign={sign}
index={index}
onChange={updateSign}
onPhoto={handlePhoto}
onRemove={removeSign}
canRemove={signs.length > 1}
/>
))}
</div>
</div>
</div>
</Layout>
);
}
function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
const mainInputRef = useRef(null);
const contextInputRef1 = useRef(null);
const contextInputRef2 = useRef(null);
const contextInputRef3 = useRef(null);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
{sign.signName || `Sign ${index + 1}`}
</div>
{canRemove && (
<button className="btn btn-outline btn-sm" onClick={() => onRemove(sign.id)}>
Remove
</button>
)}
</div>
<div className="grid-2">
<div className="form-group">
<label>Sign Name</label>
<input
type="text"
value={sign.signName}
onChange={(event) => onChange(sign.id, 'signName', event.target.value)}
placeholder={`e.g. Sign ${index + 1}`}
/>
</div>
<div className="form-group">
<label>Main Photo</label>
<PhotoPicker
inputRef={mainInputRef}
preview={sign.mainPreview}
label="Click to upload main sign photo"
onPick={(file) => onPhoto(sign.id, 'mainPhoto', 'mainPreview', file)}
/>
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Context Photos</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<PhotoPicker
inputRef={contextInputRef1}
preview={sign.contextPreview1}
label="Context photo 1"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto1', 'contextPreview1', file)}
/>
<PhotoPicker
inputRef={contextInputRef2}
preview={sign.contextPreview2}
label="Context photo 2"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto2', 'contextPreview2', file)}
/>
<PhotoPicker
inputRef={contextInputRef3}
preview={sign.contextPreview3}
label="Context photo 3"
small
onPick={(file) => onPhoto(sign.id, 'contextPhoto3', 'contextPreview3', file)}
/>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Measurements / Info</label>
<textarea
value={sign.measurements}
onChange={(event) => onChange(sign.id, 'measurements', event.target.value)}
placeholder={'e.g. 48" W x 24" H\nAluminum panel\nMounted to brick facade'}
style={{ minHeight: 110 }}
/>
</div>
<div className="form-group">
<label>Notes</label>
<textarea
value={sign.notes}
onChange={(event) => onChange(sign.id, 'notes', event.target.value)}
placeholder="Additional survey notes..."
style={{ minHeight: 110 }}
/>
</div>
</div>
</div>
);
}
function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (event) => {
event.preventDefault();
dragCounter.current += 1;
setDragging(true);
};
const handleDragLeave = (event) => {
event.preventDefault();
dragCounter.current -= 1;
if (dragCounter.current === 0) setDragging(false);
};
const handleDragOver = (event) => {
event.preventDefault();
};
const handleDrop = (event) => {
event.preventDefault();
dragCounter.current = 0;
setDragging(false);
const file = event.dataTransfer.files?.[0];
if (isPhotoFile(file)) onPick(file);
};
return (
<>
<div
onClick={() => inputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
minHeight: small ? 110 : 120,
cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
padding: 10,
transition: 'border-color 0.15s, background 0.15s',
}}
>
{preview ? (
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
{dragging ? 'Drop photo here' : label}
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target.files?.[0];
if (isPhotoFile(file)) onPick(file);
event.target.value = '';
}}
/>
</>
);
}