Files
fourge-portal/src/pages/BrandBook.jsx
T
Krao Hasanee ff159c5937 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>
2026-05-19 22:14:49 -04:00

3714 lines
153 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
const BUCKET = 'brand-books';
const EMPTY_SIGN = () => ({
_key: Math.random().toString(36).slice(2),
signNumber: '',
type: '',
recommendation: '',
specifications: '',
notes: '',
photo: null,
photoPath: '',
_photoPreview: '',
existingPhoto: null,
existingPhotoPath: '',
_existingPhotoPreview: '',
recommendationPhoto: null,
recommendationPhotoPath: '',
_recommendationPhotoPreview: '',
signDetailPhoto: null,
signDetailPhotoPath: '',
_signDetailPhotoPreview: '',
});
const EMPTY_BOOK_INFO = {
clientId: '',
clientName: '',
projectName: '',
siteAddress: '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: '',
revision: '01',
template: 'fourge',
// Cover page
creationDate: new Date().toISOString().slice(0, 10),
revisionDate: '',
customerName: '',
customerAddress: '',
clientLogoUrl: '',
clientContactName: '',
clientContactEmail: '',
clientContactPhone: '',
approvedDate: '',
approvalNotes: '',
};
const TEMPLATE_OPTIONS = [
{ value: 'fourge', label: 'Fourge Branding Template' },
{ value: 'bolchoz', label: 'Bolchoz Sign Solutions' },
];
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 MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || '';
const normalizeRevision = (value) => {
const digits = String(value ?? '').replace(/\D/g, '').slice(0, 2);
if (!digits) return '00';
return digits.padStart(2, '0');
};
const 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;
};
const getMapboxCoordinates = async (address) => {
const features = await getMapboxAddressSuggestions(address, 1);
const coordinates = features[0]?.center;
if (!coordinates || coordinates.length < 2) throw new Error('No map result found for that address.');
return coordinates;
};
const getMapboxAddressSuggestions = async (address, limit = 5) => {
const params = new URLSearchParams({
access_token: MAPBOX_TOKEN,
autocomplete: 'true',
country: 'us',
limit: String(limit),
types: 'address,place,postcode,locality,neighborhood,poi',
});
const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?${params.toString()}`);
if (!response.ok) throw new Error('Unable to find that address.');
const data = await response.json();
return data.features || [];
};
const buildStaticMapUrl = ([lng, lat], style = 'satellite-streets-v12', zoom = 18) => {
if (!MAPBOX_TOKEN || lng == null || lat == null) return '';
const marker = `pin-s+f5a523(${lng},${lat})`;
const camera = `${lng},${lat},${zoom},0`;
return `https://api.mapbox.com/styles/v1/mapbox/${style}/static/${marker}/${camera}/700x467@2x?access_token=${encodeURIComponent(MAPBOX_TOKEN)}`;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getSignedUrl = async (path) => {
if (!path) return null;
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
return data?.signedUrl || null;
};
const uploadFile = async (file, path) => {
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
if (error) throw error;
return path;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export default function BrandBook() {
const [view, setView] = useState('list');
const [savedBooks, setSavedBooks] = useState([]);
const [loadingBooks, setLoadingBooks] = useState(true);
const [currentId, setCurrentId] = useState(null);
const [clients, setClients] = useState([]);
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
const [siteMapFile, setSiteMapFile] = useState(null);
const [siteMapPath, setSiteMapPath] = useState('');
const [siteMapPreview, setSiteMapPreview] = useState(null);
const [autoMapAddress, setAutoMapAddress] = useState('');
const [autoMapLoading, setAutoMapLoading] = useState(false);
const [autoMapError, setAutoMapError] = useState('');
const [siteMapManuallyCleared, setSiteMapManuallyCleared] = useState(false);
const [inventoryMapManuallyCleared, setInventoryMapManuallyCleared] = useState(false);
const [addressSuggestions, setAddressSuggestions] = useState([]);
const [addressSuggesting, setAddressSuggesting] = useState(false);
const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [photoItems, setPhotoItems] = useState([]);
const [inventoryMapFile, setInventoryMapFile] = useState(null);
const [inventoryMapPath, setInventoryMapPath] = useState('');
const [inventoryMapPreview, setInventoryMapPreview] = useState(null);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState(null);
const [projectLogoFile, setProjectLogoFile] = useState(null);
const [projectLogoPath, setProjectLogoPath] = useState('');
const [projectLogoPreview, setProjectLogoPreview] = useState('');
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
const [savingClientInfo, setSavingClientInfo] = useState(false);
const [clientInfoSaved, setClientInfoSaved] = useState(false);
const siteMapRef = useRef();
const inventoryMapRef = useRef();
const sitePhotosRef = useRef();
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
fetchBooks();
}, []);
useEffect(() => {
const address = bookInfo.customerAddress.trim();
if (!address || autoMapAddress === address) return undefined;
if ((siteMapManuallyCleared || siteMapPath || (siteMapFile && !autoMapAddress))
&& (inventoryMapManuallyCleared || inventoryMapPath || (inventoryMapFile && !autoMapAddress))) return undefined;
const timeoutId = setTimeout(() => {
if (address.includes(',') && address.length >= 12) {
loadMapsFromAddress(address, { silent: true });
}
}, 900);
return () => clearTimeout(timeoutId);
}, [
bookInfo.customerAddress,
siteMapPath,
siteMapFile,
siteMapManuallyCleared,
inventoryMapPath,
inventoryMapFile,
inventoryMapManuallyCleared,
autoMapAddress,
]);
useEffect(() => {
const query = bookInfo.customerAddress.trim();
if (!MAPBOX_TOKEN || query.length < 3) {
setAddressSuggestions([]);
setAddressSuggesting(false);
return undefined;
}
let cancelled = false;
const timeoutId = setTimeout(async () => {
setAddressSuggesting(true);
try {
const suggestions = await getMapboxAddressSuggestions(query, 5);
if (!cancelled) setAddressSuggestions(suggestions);
} catch {
if (!cancelled) setAddressSuggestions([]);
} finally {
if (!cancelled) setAddressSuggesting(false);
}
}, 250);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [bookInfo.customerAddress]);
const fetchBooks = async () => {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
setLoadingBooks(false);
};
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
const handleRevisionChange = (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 2);
setBookInfo(b => ({ ...b, revision: digits }));
};
const handleRevisionBlur = () => {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
};
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim();
if (!trimmed) {
if (!silent) setAutoMapError('Enter a customer address first.');
return;
}
if (!MAPBOX_TOKEN) {
if (!silent) setAutoMapError('Mapbox token is not configured yet. Add VITE_MAPBOX_TOKEN in Vercel to enable automatic site maps.');
return;
}
if (autoMapLoading) return;
setAutoMapLoading(true);
setAutoMapError('');
try {
const coordinates = feature?.center || await getMapboxCoordinates(trimmed);
const shouldLoadSiteMap = !siteMapManuallyCleared || !siteMapFile;
const shouldLoadInventoryMap = !inventoryMapManuallyCleared || !inventoryMapFile;
const [siteResponse, inventoryResponse] = await Promise.all([
shouldLoadSiteMap ? fetch(buildStaticMapUrl(coordinates, 'satellite-streets-v12')) : Promise.resolve(null),
shouldLoadInventoryMap ? fetch(buildStaticMapUrl(coordinates, 'streets-v12', 18.2)) : Promise.resolve(null),
]);
if ((siteResponse && !siteResponse.ok) || (inventoryResponse && !inventoryResponse.ok)) throw new Error('Unable to load map image.');
const [siteBlob, inventoryBlob] = await Promise.all([
siteResponse ? siteResponse.blob() : Promise.resolve(null),
inventoryResponse ? inventoryResponse.blob() : Promise.resolve(null),
]);
if ((siteBlob && !siteBlob.type.startsWith('image/')) || (inventoryBlob && !inventoryBlob.type.startsWith('image/'))) throw new Error('Map service did not return an image.');
const fullAddress = feature?.place_name || trimmed;
setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress }));
if (siteBlob) {
const siteFile = new File([siteBlob], 'site-map.png', { type: siteBlob.type || 'image/png' });
setSiteMapFile(siteFile);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(siteFile));
setSiteMapManuallyCleared(false);
}
if (inventoryBlob) {
const inventoryFile = new File([inventoryBlob], 'inventory-map.png', { type: inventoryBlob.type || 'image/png' });
setInventoryMapFile(inventoryFile);
setInventoryMapPath('');
setInventoryMapPreview(URL.createObjectURL(inventoryFile));
setInventoryMapManuallyCleared(false);
}
setAutoMapAddress(fullAddress);
setShowAddressSuggestions(false);
} catch (error) {
setAutoMapError(error.message || 'Unable to load map for this address.');
} finally {
setAutoMapLoading(false);
}
};
const handleCustomerAddressChange = (e) => {
const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
setAutoMapError('');
setSiteMapManuallyCleared(false);
setInventoryMapManuallyCleared(false);
setShowAddressSuggestions(true);
};
const handleSelectAddressSuggestion = (feature) => {
const fullAddress = feature.place_name || '';
setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress }));
setAddressSuggestions([]);
setShowAddressSuggestions(false);
loadMapsFromAddress(fullAddress, { feature });
};
const clearSiteMap = () => {
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setSiteMapManuallyCleared(true);
};
const clearInventoryMap = () => {
setInventoryMapFile(null);
setInventoryMapPath('');
setInventoryMapPreview(null);
setInventoryMapManuallyCleared(true);
};
const handleClientChange = async (e) => {
const id = e.target.value;
const client = clients.find(c => c.id === id);
const autoTemplate = client?.name?.toLowerCase().includes('bolchoz') ? 'bolchoz' : 'fourge';
setBookInfo(b => ({
...b,
clientId: id,
clientName: client ? client.name : '',
customerName: b.customerName || '',
template: autoTemplate,
}));
if (id) {
const { data: co } = await supabase.from('companies').select('*').eq('id', id).single();
if (co) {
setBookInfo(b => ({
...b,
clientLogoUrl: co.client_logo_url || '',
clientContactName: co.contact_name || '',
clientContactEmail: co.contact_email || '',
clientContactPhone: co.contact_phone || '',
customerAddress: b.customerAddress || co.address || b.siteAddress || '',
siteAddress: b.siteAddress || b.customerAddress || co.address || '',
}));
}
}
};
const resetForm = () => {
setBookInfo(EMPTY_BOOK_INFO);
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setAutoMapAddress('');
setAutoMapError('');
setSiteMapManuallyCleared(false);
setInventoryMapManuallyCleared(false);
setAddressSuggestions([]);
setShowAddressSuggestions(false);
setInventoryMapFile(null);
setInventoryMapPath('');
setInventoryMapPreview(null);
setSigns([EMPTY_SIGN()]);
setPhotoItems([]);
setCurrentId(null);
setNotification(null);
setProjectLogoFile(null);
setProjectLogoPath('');
setProjectLogoPreview('');
};
const handleNew = () => { resetForm(); setView('form'); };
const buildLoadedState = async (book) => {
const [siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl] = await Promise.all([
getSignedUrl(book.site_map_path),
getSignedUrl(book.inventory_map_path),
getSignedUrl(book.project_logo_path),
]);
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
const [preview, existingPreview, recommendationPreview, signDetailPreview] = await Promise.all([
getSignedUrl(sign.photoPath),
getSignedUrl(sign.existingPhotoPath),
getSignedUrl(sign.recommendationPhotoPath),
getSignedUrl(sign.signDetailPhotoPath),
]);
return {
...sign,
photo: null,
_photoPreview: preview || '',
existingPhoto: null,
_existingPhotoPreview: existingPreview || '',
recommendationPhoto: null,
_recommendationPhotoPreview: recommendationPreview || '',
signDetailPhoto: null,
_signDetailPhotoPreview: signDetailPreview || '',
};
}));
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
const preview = await getSignedUrl(path);
return { file: null, path, preview: preview || '' };
}));
return { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems };
};
const handleLoad = async (book) => {
setNotification(null);
const { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: book.book_date || new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: normalizeRevision(book.revision || '01'),
template: book.template || 'fourge',
creationDate: book.creation_date || new Date().toISOString().slice(0, 10),
revisionDate: book.revision_date || '',
customerName: book.customer_name || '',
customerAddress: book.customer_address || book.site_address || '',
clientLogoUrl: book.client_logo_url || '',
clientContactName: book.client_contact_name || '',
clientContactEmail: book.client_contact_email || '',
clientContactPhone: book.client_contact_phone || '',
approvedDate: book.approved_date || '',
approvalNotes: book.approval_notes || '',
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setInventoryMapFile(null);
setInventoryMapPath(book.inventory_map_path || '');
setInventoryMapPreview(inventoryMapSignedUrl);
setProjectLogoFile(null);
setProjectLogoPath(book.project_logo_path || '');
setProjectLogoPreview(projectLogoSignedUrl || '');
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(book.id);
setView('form');
};
const handleDelete = async (book) => {
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
await cleanupBrandBookStorage(book);
await supabase.from('brand_books').delete().eq('id', book.id);
fetchBooks();
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setSaving(true);
setNotification(null);
try {
const bookId = currentId || crypto.randomUUID();
// Upload project logo if new file
let finalProjectLogoPath = projectLogoPath;
if (projectLogoFile) {
const ext = projectLogoFile.name.split('.').pop().toLowerCase();
finalProjectLogoPath = `${bookId}/project-logo.${ext}`;
await uploadFile(projectLogoFile, finalProjectLogoPath);
}
// Upload site map if new file
let finalSiteMapPath = siteMapPath;
if (siteMapFile) {
const ext = siteMapFile.name.split('.').pop().toLowerCase();
finalSiteMapPath = `${bookId}/site-map.${ext}`;
await uploadFile(siteMapFile, finalSiteMapPath);
}
// Upload inventory map if new file
let finalInventoryMapPath = inventoryMapPath;
if (inventoryMapFile) {
const ext = inventoryMapFile.name.split('.').pop().toLowerCase();
finalInventoryMapPath = `${bookId}/inventory-map.${ext}`;
await uploadFile(inventoryMapFile, finalInventoryMapPath);
}
// Upload sign photos if new file
const finalSigns = await Promise.all(signs.map(async (sign) => {
let photoPath = sign.photoPath || '';
if (sign.photo) {
const ext = sign.photo.name.split('.').pop().toLowerCase();
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
await uploadFile(sign.photo, photoPath);
}
let existingPhotoPath = sign.existingPhotoPath || '';
if (sign.existingPhoto) {
const ext = sign.existingPhoto.name.split('.').pop().toLowerCase();
existingPhotoPath = `${bookId}/sign-${sign._key}-existing.${ext}`;
await uploadFile(sign.existingPhoto, existingPhotoPath);
}
let recommendationPhotoPath = sign.recommendationPhotoPath || '';
if (sign.recommendationPhoto) {
const ext = sign.recommendationPhoto.name.split('.').pop().toLowerCase();
recommendationPhotoPath = `${bookId}/sign-${sign._key}-recommendation.${ext}`;
await uploadFile(sign.recommendationPhoto, recommendationPhotoPath);
}
let signDetailPhotoPath = sign.signDetailPhotoPath || '';
if (sign.signDetailPhoto) {
const ext = sign.signDetailPhoto.name.split('.').pop().toLowerCase();
signDetailPhotoPath = `${bookId}/sign-${sign._key}-detail.${ext}`;
await uploadFile(sign.signDetailPhoto, signDetailPhotoPath);
}
const {
photo: _photo,
_photoPreview,
existingPhoto: _existingPhoto,
_existingPhotoPreview,
recommendationPhoto: _recommendationPhoto,
_recommendationPhotoPreview,
signDetailPhoto: _signDetailPhoto,
_signDetailPhotoPreview,
...rest
} = sign;
return { ...rest, photoPath, existingPhotoPath, recommendationPhotoPath, signDetailPhotoPath };
}));
// Upload survey photos if new file
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
if (item.file) {
const ext = item.file.name.split('.').pop().toLowerCase();
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
await uploadFile(item.file, path);
return path;
}
return item.path;
}));
const dbData = {
id: bookId,
client_id: bookInfo.clientId || null,
client_name: bookInfo.clientName,
project_name: bookInfo.projectName,
site_address: bookInfo.siteAddress,
book_date: bookInfo.bookDate || null,
prepared_by: bookInfo.preparedBy,
revision: normalizeRevision(bookInfo.revision),
template: bookInfo.template || 'fourge',
site_map_path: finalSiteMapPath,
inventory_map_path: finalInventoryMapPath || null,
signs: finalSigns,
survey_photo_paths: finalSurveyPaths.filter(Boolean),
updated_at: new Date().toISOString(),
// Cover page fields
project_logo_path: finalProjectLogoPath || null,
creation_date: bookInfo.creationDate || null,
revision_date: bookInfo.revisionDate || null,
customer_name: bookInfo.customerName || null,
customer_address: bookInfo.customerAddress || null,
client_logo_url: bookInfo.clientLogoUrl || null,
client_contact_name: bookInfo.clientContactName || null,
client_contact_email: bookInfo.clientContactEmail || null,
client_contact_phone: bookInfo.clientContactPhone || null,
approved_date: bookInfo.approvedDate || null,
approval_notes: bookInfo.approvalNotes || null,
};
const { error: upsertError } = await supabase.from('brand_books').upsert(dbData);
if (upsertError) throw upsertError;
const savedSignsWithPreviews = await Promise.all(finalSigns.map(async (sign) => {
const [photoPreview, existingPhotoPreview, recommendationPhotoPreview, signDetailPhotoPreview] = await Promise.all([
getSignedUrl(sign.photoPath),
getSignedUrl(sign.existingPhotoPath),
getSignedUrl(sign.recommendationPhotoPath),
getSignedUrl(sign.signDetailPhotoPath),
]);
return {
...sign,
photo: null,
existingPhoto: null,
recommendationPhoto: null,
signDetailPhoto: null,
_photoPreview: photoPreview || '',
_existingPhotoPreview: existingPhotoPreview || '',
_recommendationPhotoPreview: recommendationPhotoPreview || '',
_signDetailPhotoPreview: signDetailPhotoPreview || '',
};
}));
setCurrentId(bookId);
setSiteMapPath(finalSiteMapPath);
setSiteMapFile(null);
setInventoryMapPath(finalInventoryMapPath);
setInventoryMapFile(null);
setProjectLogoPath(finalProjectLogoPath);
setProjectLogoFile(null);
setSigns(savedSignsWithPreviews);
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
file: null,
path,
preview: photoItems[i]?.preview || '',
})));
await fetchBooks();
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
} catch (err) {
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
} finally {
setSaving(false);
}
};
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
return;
}
setGenerating(true);
setNotification(null);
try {
const [siteMapSource, inventoryMapSource] = await Promise.all([
siteMapFile ? Promise.resolve(siteMapFile) : (siteMapPath ? getSignedUrl(siteMapPath) : null),
inventoryMapFile ? Promise.resolve(inventoryMapFile) : (inventoryMapPath ? getSignedUrl(inventoryMapPath) : null),
]);
const signsWithSource = await Promise.all(signs.map(async s => ({
...s,
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
existingPhotoSource: s.existingPhoto || (s.existingPhotoPath ? await getSignedUrl(s.existingPhotoPath) : null),
recommendationPhotoSource: s.recommendationPhoto || (s.recommendationPhotoPath ? await getSignedUrl(s.recommendationPhotoPath) : null),
signDetailPhotoSource: s.signDetailPhoto || (s.signDetailPhotoPath ? await getSignedUrl(s.signDetailPhotoPath) : null),
})));
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
item.file || (item.path ? await getSignedUrl(item.path) : null)
));
const projectLogoSource = projectLogoFile || (projectLogoPath ? await getSignedUrl(projectLogoPath) : null);
await generateBrandBookEditorPDF({
template: bookInfo.template || 'fourge',
clientName: bookInfo.clientName,
projectName: bookInfo.projectName,
siteAddress: bookInfo.siteAddress,
bookDate: bookInfo.bookDate,
preparedBy: bookInfo.preparedBy,
revision: normalizeRevision(bookInfo.revision),
siteMapSource,
inventoryMapSource,
signs: signsWithSource,
sitePhotoSources,
// Cover page
projectLogoSource,
creationDate: bookInfo.creationDate,
revisionDate: bookInfo.revisionDate,
customerName: bookInfo.customerName,
customerAddress: bookInfo.customerAddress || bookInfo.siteAddress,
clientLogoSource: bookInfo.clientLogoUrl || null,
clientContactName: bookInfo.clientContactName,
clientContactEmail: bookInfo.clientContactEmail,
clientContactPhone: bookInfo.clientContactPhone,
approvedDate: bookInfo.approvedDate,
approvalNotes: bookInfo.approvalNotes,
});
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
} finally {
setGenerating(false);
}
};
// ── Project logo helpers ──────────────────────────────────────────────────────
const handleProjectLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
setProjectLogoFile(file);
setProjectLogoPreview(URL.createObjectURL(file));
setProjectLogoPath('');
};
// ── Client logo / info helpers ────────────────────────────────────────────────
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`;
await supabase.storage.from('company-logos').remove([path]);
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
if (!error) {
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', bookInfo.clientId);
setBookInfo(b => ({ ...b, clientLogoUrl: publicUrl }));
}
setUploadingClientLogo(false);
};
const handleSaveClientInfo = async () => {
if (!bookInfo.clientId) return;
setSavingClientInfo(true);
await supabase.from('companies').update({
address: bookInfo.customerAddress || null,
contact_name: bookInfo.clientContactName || null,
contact_email: bookInfo.clientContactEmail || null,
contact_phone: bookInfo.clientContactPhone || null,
}).eq('id', bookInfo.clientId);
setSavingClientInfo(false);
setClientInfoSaved(true);
setTimeout(() => setClientInfoSaved(false), 2500);
};
// ── Sign helpers ─────────────────────────────────────────────────────────────
const updateSign = (key, field, value) =>
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); };
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
const handleSignPhoto = (key, field, file) => {
const previewField = `_${field}Preview`;
const pathField = `${field}Path`;
updateSign(key, field, file);
updateSign(key, previewField, URL.createObjectURL(file));
updateSign(key, pathField, '');
};
// ── Map helpers ───────────────────────────────────────────────────────────────
const handleSiteMapFile = (file) => {
setSiteMapFile(file);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(file));
setAutoMapAddress('');
setAutoMapError('');
setSiteMapManuallyCleared(false);
};
const handleInventoryMapFile = (file) => {
setInventoryMapFile(file);
setInventoryMapPath('');
setInventoryMapPreview(URL.createObjectURL(file));
setAutoMapAddress('');
setAutoMapError('');
setInventoryMapManuallyCleared(false);
};
// ── Survey photo helpers ──────────────────────────────────────────────────────
const handleSitePhotos = (files) => {
const newItems = Array.from(files)
.filter(isPhotoFile)
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
setPhotoItems(prev => [...prev, ...newItems]);
};
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
// ── Unsaved changes indicator ─────────────────────────────────────────────────
const hasUnsavedPhotos = siteMapFile || inventoryMapFile || signs.some(s => (
s.photo || s.existingPhoto || s.recommendationPhoto || s.signDetailPhoto
)) || photoItems.some(i => i.file);
// ═══════════════════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════════════════
if (view === 'list') {
const companyNames = [...new Set(savedBooks.map(book => book.client_name || clients.find(client => client.id === book.client_id)?.name).filter(Boolean))].sort();
const filteredBooks = savedBooks.filter(book => {
if (!filterCompany) return true;
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name;
return clientName === filterCompany;
});
return (
<Layout>
<div className="page-header">
<div>
<div className="page-title">
Brand Book Maker
</div>
<div className="page-subtitle">Saved brand books click to edit or add a revision.</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleNew}>+ New Brand Book</button>
</div>
</div>
{companyNames.length > 0 && (
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
</select>
</div>
)}
{loadingBooks ? (
<p style={{ padding: '24px 0', color: 'var(--text-muted)' }}>Loading...</p>
) : filteredBooks.length === 0 ? (
<div className="empty-state">
<h3>{savedBooks.length === 0 ? 'No brand books yet' : 'No matching brand books'}</h3>
<p>{savedBooks.length === 0 ? 'Create your first brand book to get started.' : 'Try clearing the current company filter.'}</p>
<button className="btn btn-primary" onClick={handleNew} style={{ marginTop: 16 }}>+ New Brand Book</button>
</div>
) : (
<div className="table-wrapper">
<table>
<thead>
<tr>
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
<th></th>
</tr>
</thead>
<tbody>
{bbSort(filteredBooks, (book, key) => {
if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0;
if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || '';
if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0;
return book[key] || '';
}).map(book => {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
const updated = book.updated_at
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
<td>{signCount}</td>
<td>{clientName}</td>
<td>{updated}</td>
<td onClick={e => e.stopPropagation()}>
<button
className="btn-icon btn-icon-danger"
onClick={() => handleDelete(book)}
title="Delete"
>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Layout>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORM VIEW
// ═══════════════════════════════════════════════════════════════════════════════
return (
<Layout>
<button className="back-link" onClick={() => setView('list')}> All Brand Books</button>
<div className="page-header">
<div>
<div className="page-title">
{currentId
? `${bookInfo.projectName || 'Brand Book'} R${String(bookInfo.revision).padStart(2, '0')}`
: 'New Brand Book'}
</div>
<div className="page-subtitle">
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
{hasUnsavedPhotos && <span style={{ color: 'var(--accent)', marginLeft: 8 }}>· Unsaved photos</span>}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>Discard</button>
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
{notification.msg}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project Name</label>
<input type="text" placeholder="e.g. Main Street Rebrand 2025" value={bookInfo.projectName} onChange={set('projectName')} />
</div>
</div>
<div className="form-group" style={{ maxWidth: 340 }}>
<label>Template</label>
<select value={bookInfo.template} onChange={set('template')}>
{TEMPLATE_OPTIONS.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div className="form-group" style={{ maxWidth: 180 }}>
<label>Revision</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>R</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={2}
value={bookInfo.revision}
onChange={handleRevisionChange}
onBlur={handleRevisionBlur}
placeholder="00"
style={{ maxWidth: 88 }}
/>
</div>
</div>
</div>
{/* ── COVER PAGE ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Cover Page</div>
{/* Project logo */}
<div className="form-group">
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{projectLogoPreview && (
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
{projectLogoPreview ? 'Replace Logo' : 'Upload Logo'}
</button>
{projectLogoPreview && (
<button className="btn btn-outline btn-sm" style={{ marginLeft: 8, color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={() => { setProjectLogoFile(null); setProjectLogoPath(''); setProjectLogoPreview(''); }}>
Remove
</button>
)}
<input ref={projectLogoRef} type="file" accept={PHOTO_FILE_ACCEPT} style={{ display: 'none' }} onChange={handleProjectLogoUpload} />
</div>
</div>
</div>
{/* Dates */}
<div className="grid-2">
<div className="form-group">
<label>Creation Date</label>
<input type="date" value={bookInfo.creationDate} onChange={set('creationDate')} />
</div>
<div className="form-group">
<label>Revision Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={bookInfo.revisionDate} onChange={set('revisionDate')} />
</div>
</div>
{/* Customer info */}
<div className="grid-2">
<div className="form-group">
<label>Customer Name</label>
<input type="text" placeholder="e.g. Bolchoz Sign Solutions" value={bookInfo.customerName} onChange={set('customerName')} />
</div>
<div className="form-group">
<label>Customer Address <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="e.g. 123 Main St, City, State 00000"
value={bookInfo.customerAddress}
onChange={handleCustomerAddressChange}
onFocus={() => setShowAddressSuggestions(true)}
style={{ margin: 0 }}
/>
<LoadingButton
className="btn btn-outline btn-sm"
loading={autoMapLoading}
disabled={autoMapLoading || !bookInfo.customerAddress.trim()}
loadingText="Loading..."
onClick={() => loadMapsFromAddress()}
>
Load Maps
</LoadingButton>
</div>
{showAddressSuggestions && (addressSuggestions.length > 0 || addressSuggesting) && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 20,
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--card-bg)',
boxShadow: '0 16px 36px rgba(0,0,0,0.28)',
overflow: 'hidden',
}}
>
{addressSuggesting && (
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-muted)' }}>Searching...</div>
)}
{addressSuggestions.map(feature => (
<button
key={feature.id}
type="button"
onClick={() => handleSelectAddressSuggestion(feature)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '10px 12px',
border: 'none',
borderTop: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 13,
}}
>
{feature.place_name}
</button>
))}
</div>
)}
</div>
{autoMapError && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>{autoMapError}</div>
)}
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 20px' }} />
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company reused across all brand books.</div>
</div>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
{savingClientInfo ? 'Saving...' : clientInfoSaved ? '✓ Saved' : 'Save to Company'}
</button>
</div>
<div className="form-group">
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{bookInfo.clientLogoUrl && (
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
)}
<div>
<button className="btn btn-outline btn-sm" onClick={() => clientLogoRef.current?.click()} disabled={uploadingClientLogo}>
{uploadingClientLogo ? 'Uploading...' : bookInfo.clientLogoUrl ? 'Replace Logo' : 'Upload Logo'}
</button>
{!bookInfo.clientId && <span style={{ marginLeft: 10, fontSize: 12, color: 'var(--text-muted)' }}>Select a client first</span>}
<input ref={clientLogoRef} type="file" accept={PHOTO_FILE_ACCEPT} style={{ display: 'none' }} onChange={handleClientLogoUpload} />
</div>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label>Contact Name</label>
<input type="text" placeholder="e.g. Jane Smith" value={bookInfo.clientContactName} onChange={set('clientContactName')} />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" placeholder="e.g. jane@client.com" value={bookInfo.clientContactEmail} onChange={set('clientContactEmail')} />
</div>
</div>
<div className="form-group" style={{ maxWidth: 320 }}>
<label>Phone</label>
<input type="text" placeholder="e.g. (555) 000-0000" value={bookInfo.clientContactPhone} onChange={set('clientContactPhone')} />
</div>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 20px' }} />
{/* Approval */}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
<div className="form-group" style={{ maxWidth: 280 }}>
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<input type="date" value={bookInfo.approvedDate} onChange={set('approvedDate')} />
</div>
<div className="form-group">
<label>Approval Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
<textarea placeholder="Any approval notes or conditions..." value={bookInfo.approvalNotes} onChange={set('approvalNotes')} style={{ minHeight: 70 }} />
</div>
</div>
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Map</div>
<div className="form-group">
<label>Site Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional gets its own dedicated page in the PDF)</span></label>
<SiteMapDropZone
preview={siteMapPreview}
onFile={handleSiteMapFile}
onClear={clearSiteMap}
inputRef={siteMapRef}
/>
</div>
</div>
{/* ── INVENTORY MAP ─────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Map</div>
<div className="form-group">
<label>Inventory Map Image <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(shown on the left side of the sign inventory page different from the site map)</span></label>
<SiteMapDropZone
preview={inventoryMapPreview}
onFile={handleInventoryMapFile}
onClear={clearInventoryMap}
inputRef={inventoryMapRef}
/>
</div>
</div>
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
<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: 'flex', flexDirection: 'column', gap: 8 }}>
{signs.map((sign, i) => (
<SignCard
key={sign._key}
sign={sign}
index={i}
onChange={(field, val) => updateSign(sign._key, field, val)}
onPhotoChange={(field, file) => handleSignPhoto(sign._key, field, file)}
onRemove={() => removeSign(sign._key)}
canRemove={signs.length > 1}
template={bookInfo.template}
/>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<button className="btn btn-outline btn-sm" onClick={addSign}>+ Add Sign</button>
</div>
</div>
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
<div className="card">
<div className="card-title">Site Photos</div>
<div className="form-group">
<label>Site Photos <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(4×4 grid, 16 per page additional pages added automatically)</span></label>
<SitePhotosDropZone
photoItems={photoItems}
onFiles={handleSitePhotos}
onRemove={removeSitePhoto}
inputRef={sitePhotosRef}
/>
</div>
</div>
</div>
{/* Bottom actions */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, marginTop: 24, paddingBottom: 40 }}>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-outline btn-sm" onClick={() => { if (window.confirm('Discard changes?')) { resetForm(); setView('list'); } }}>
Discard
</button>
<button className="btn btn-outline btn-sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
{notification.msg}
</span>
)}
</div>
</Layout>
);
}
// ─── Book List Item ───────────────────────────────────────────────────────────
// ─── Sign Card ────────────────────────────────────────────────────────────────
function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, template }) {
const photoInputRef = useRef();
const existingPhotoInputRef = useRef();
const recommendationPhotoInputRef = useRef();
const signDetailPhotoInputRef = useRef();
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (field) => (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (isPhotoFile(file)) onPhotoChange(field, file);
};
const summary = [sign.type, sign.recommendation].filter(Boolean).join(' — ') || 'New Sign';
const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview;
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
#{sign.signNumber || (index + 1)}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && (
<span role="button" onClick={onRemove}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span>
)}
</div>
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Sign # / ID</label>
<input type="text" placeholder={`e.g. ${index + 1}`} value={sign.signNumber} onChange={e => onChange('signNumber', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Existing Type</label>
<input type="text" placeholder="e.g. Monument, Channel Letter, Pylon…" value={sign.type} onChange={e => onChange('type', e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Recommendation</label>
<input type="text" placeholder="e.g. New Monument Sign" value={sign.recommendation} onChange={e => onChange('recommendation', e.target.value)} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="form-group" style={{ margin: 0 }}>
<label>Specifications</label>
<textarea
placeholder="Detailed specifications for the existing sign..."
value={sign.specifications}
onChange={e => onChange('specifications', e.target.value)}
style={{ minHeight: 90 }}
/>
</div>
<div className="form-group" style={{ margin: 0 }}>
<label>Notes</label>
<textarea
placeholder="Additional notes..."
value={sign.notes}
onChange={e => onChange('notes', e.target.value)}
style={{ minHeight: 90 }}
/>
</div>
</div>
{template === 'bolchoz' ? (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 1fr) minmax(420px, 2fr)', gap: 12 }}>
<PhotoField
label="Existing Photo"
preview={sign._existingPhotoPreview}
fileName={sign.existingPhoto?.name}
dragging={dragging}
inputRef={existingPhotoInputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop('existingPhoto')}
onPick={(file) => onPhotoChange('existingPhoto', file)}
/>
<CombinedMockupPhotoField
recommendationPreview={sign._recommendationPhotoPreview}
recommendationFileName={sign.recommendationPhoto?.name}
signDetailPreview={sign._signDetailPhotoPreview}
signDetailFileName={sign.signDetailPhoto?.name}
baseSourceImage={sign._existingPhotoPreview || sign._photoPreview || null}
dragging={dragging}
recommendationInputRef={recommendationPhotoInputRef}
signDetailInputRef={signDetailPhotoInputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onRecommendationDrop={handleDrop('recommendationPhoto')}
onSignDetailDrop={handleDrop('signDetailPhoto')}
onRecommendationPick={(file) => onPhotoChange('recommendationPhoto', file)}
onSignDetailPick={(file) => onPhotoChange('signDetailPhoto', file)}
/>
</div>
) : (
<PhotoField
label="Sign Photo"
preview={sign._photoPreview}
fileName={sign.photo?.name}
dragging={dragging}
inputRef={photoInputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop('photo')}
onPick={(file) => onPhotoChange('photo', file)}
/>
)}
</div>
</div>
);
}
function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick }) {
return (
<div className="form-group" style={{ margin: 0 }}>
<label>{label}</label>
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
}}
>
{preview ? (
<>
<img src={preview} alt={label} style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{fileName || 'Saved photo'}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to replace · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
</div>
);
}
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (isPhotoFile(file)) onFile(file);
};
if (preview) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
<div>
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
</div>
</div>
);
}
return (
<>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
</div>
<input ref={inputRef} type="file" accept={PHOTO_FILE_ACCEPT} style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
</>
);
}
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => { e.preventDefault(); dragCounter.current = 0; setDragging(false); onFiles(e.dataTransfer.files); };
return (
<div>
<div
onDragEnter={handleDragEnter} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
</div>
<input ref={inputRef} type="file" accept={PHOTO_FILE_ACCEPT} multiple style={{ display: 'none' }}
onChange={e => { if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
{photoItems.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{photoItems.map((item, i) => (
<div key={i} style={{ position: 'relative' }}>
<img
src={item.preview}
alt={item.file?.name || `photo ${i + 1}`}
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/>
{item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
)}
<button
type="button"
onClick={() => onRemove(i)}
style={{
position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
></button>
</div>
))}
<div style={{ fontSize: 11, color: 'var(--text-muted)', alignSelf: 'flex-end', marginLeft: 4 }}>
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
{photoItems.length > 12 && <span style={{ color: 'var(--accent)' }}> (first 12 shown in PDF)</span>}
</div>
</div>
)}
</div>
);
}
// ─── Combined Mockup Photo Field (one editor for both outputs) ────────────────
function CombinedMockupPhotoField({
recommendationPreview,
recommendationFileName,
signDetailPreview,
signDetailFileName,
baseSourceImage,
dragging,
recommendationInputRef,
signDetailInputRef,
onDragEnter,
onDragLeave,
onDragOver,
onRecommendationDrop,
onSignDetailDrop,
onRecommendationPick,
onSignDetailPick,
}) {
const [showEditor, setShowEditor] = useState(false);
const targetSource = (target) => {
if (target === 'recommendation') return recommendationPreview || baseSourceImage;
return signDetailPreview || recommendationPreview || baseSourceImage;
};
const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
};
const renderTile = ({ label, preview, fileName, inputRef, onDrop, onPick }) => (
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => setShowEditor(true)}
style={tileStyle}
>
{preview ? (
<>
<img src={preview} alt={label} style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{fileName || 'Saved photo'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to open shared editor</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? 'Drop photo here' : `${label}: open editor or upload`}
</div>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
style={{
position: 'absolute', top: 6, right: 6,
fontSize: 10, color: 'var(--text-muted)',
background: 'var(--card-bg-2, var(--card-bg))',
border: '1px solid var(--border)', borderRadius: 4,
padding: '2px 7px', cursor: 'pointer', lineHeight: '16px',
}}
>
Upload
</button>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
</div>
);
return (
<div className="form-group" style={{ margin: 0 }}>
<label>Mockup Photos</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{renderTile({
label: 'Recommendation Photo',
preview: recommendationPreview,
fileName: recommendationFileName,
inputRef: recommendationInputRef,
onDrop: onRecommendationDrop,
onPick: onRecommendationPick,
})}
{renderTile({
label: 'Sign Detail Photo',
preview: signDetailPreview,
fileName: signDetailFileName,
inputRef: signDetailInputRef,
onDrop: onSignDetailDrop,
onPick: onSignDetailPick,
})}
</div>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => setShowEditor(true)}
style={{ marginTop: 8 }}
>
Open Shared Mockup Editor
</button>
{showEditor && (
<PhotoEditorModal
title="Sign Mockup Editor"
subtitle="Edit recommendation and sign detail photos from the same popup."
targets={[
{
id: 'recommendation',
label: 'Recommendation',
sourceImage: targetSource('recommendation'),
applyLabel: 'Apply to Recommendation',
outputName: 'recommendation.jpg',
downloadPrefix: 'recommendation-mockup',
},
{
id: 'signDetail',
label: 'Sign Detail',
sourceImage: targetSource('signDetail'),
applyLabel: 'Apply to Sign Detail',
outputName: 'sign-detail-mockup.jpg',
downloadPrefix: 'sign-detail-mockup',
},
]}
onApply={(file, targetId) => {
if (targetId === 'signDetail') onSignDetailPick(file);
else onRecommendationPick(file);
}}
onCancel={() => setShowEditor(false)}
/>
)}
</div>
);
}
// ─── Recommendation Photo Field (click → editor, drag → direct upload) ────────
function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick, editorSourceImage }) {
const [showEditor, setShowEditor] = useState(false);
const handleUploadClick = (e) => {
e.stopPropagation();
inputRef.current?.click();
};
return (
<div className="form-group" style={{ margin: 0 }}>
<label>Recommendation Photo</label>
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
}}
>
{preview ? (
<>
<img src={preview} alt="Recommendation Photo" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{fileName || 'Saved photo'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to edit · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '✏️ Click to edit existing photo · or drag & drop'}
</div>
)}
{/* Upload button sits in corner so it doesn't trigger the editor */}
<button
type="button"
onClick={handleUploadClick}
style={{
position: 'absolute', top: 6, right: 6,
fontSize: 10, color: 'var(--text-muted)',
background: 'var(--card-bg-2, var(--card-bg))',
border: '1px solid var(--border)', borderRadius: 4,
padding: '2px 7px', cursor: 'pointer', lineHeight: '16px',
}}
>
Upload file
</button>
</div>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
{showEditor && (
<PhotoEditorModal
sourceImage={editorSourceImage}
title="Sign Mockup Editor"
subtitle="Import artwork, add dimensions, then save as the recommendation photo."
applyLabel="Apply as Recommendation Photo"
outputName="recommendation.jpg"
downloadPrefix="recommendation-mockup"
onApply={(file) => { onPick(file); setShowEditor(false); }}
onCancel={() => setShowEditor(false)}
/>
)}
</div>
);
}
// ─── Sign Detail Photo Field (click → dimension editor, drag → upload) ───────
function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick }) {
const [showEditor, setShowEditor] = useState(false);
const handleUploadClick = (e) => {
e.stopPropagation();
inputRef.current?.click();
};
return (
<div className="form-group" style={{ margin: 0 }}>
<label>Sign Detail Photo</label>
<div
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={() => preview && setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: preview ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
}}
>
{preview ? (
<>
<img src={preview} alt="Sign Detail Photo" style={{ height: 60, width: 80, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{fileName || 'Saved photo'}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Click to add dimensions · drag a new photo</div>
</div>
</>
) : (
<div style={{ color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13 }}>
{dragging ? '📂 Drop photo here' : '📐 Upload a sign detail photo to add dimensions'}
</div>
)}
<button
type="button"
onClick={handleUploadClick}
style={{
position: 'absolute', top: 6, right: 6,
fontSize: 10, color: 'var(--text-muted)',
background: 'var(--card-bg-2, var(--card-bg))',
border: '1px solid var(--border)', borderRadius: 4,
padding: '2px 7px', cursor: 'pointer', lineHeight: '16px',
}}
>
Upload file
</button>
</div>
<input
ref={inputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
{showEditor && (
<PhotoEditorModal
sourceImage={preview}
title="Sign Mockup Editor"
subtitle="Import artwork, add dimensions, then save as the sign detail photo."
applyLabel="Apply to Sign Detail Photo"
outputName="sign-detail-mockup.jpg"
downloadPrefix="sign-detail-mockup"
onApply={(file) => { onPick(file); setShowEditor(false); }}
onCancel={() => setShowEditor(false)}
/>
)}
</div>
);
}
// ─── Dimension Editor ────────────────────────────────────────────────────────
function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
const canvasRef = useRef(null);
const [baseImage, setBaseImage] = useState(null);
const [loaded, setLoaded] = useState(false);
const [dimensions, setDimensions] = useState([]);
const [draftPoints, setDraftPoints] = useState([]);
const [draftOffsetPoint, setDraftOffsetPoint] = useState(null);
const [isPlacingOffset, setIsPlacingOffset] = useState(false);
const [dimensionMode, setDimensionMode] = useState('line');
const [boxStart, setBoxStart] = useState(null);
const [boxEnd, setBoxEnd] = useState(null);
const [activeDimensionId, setActiveDimensionId] = useState(null);
const [dimensionText, setDimensionText] = useState('');
const [boxWidthText, setBoxWidthText] = useState('');
const [boxHeightText, setBoxHeightText] = useState('');
const activeDimension = dimensions.find(item => item.id === activeDimensionId) || null;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const MAX_W = 960;
const MAX_H = 580;
const scale = Math.min(MAX_W / img.naturalWidth, MAX_H / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
setBaseImage(img);
setLoaded(true);
};
img.onerror = () => {
canvas.width = 800;
canvas.height = 500;
setLoaded(true);
};
img.src = sourceImage;
}, [sourceImage]);
useEffect(() => {
renderDimensionCanvas(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImage, dimensions, draftPoints, draftOffsetPoint, activeDimensionId, boxStart, boxEnd]);
const getCanvasPos = (event) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = event.touches ? event.touches[0] : event;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY,
};
};
function drawDimension(ctx, item, selected = false) {
const color = '#000000';
const line = getDimensionLine(item);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
const dimensionLineWidth = selected ? 3 : 2.25;
ctx.lineWidth = dimensionLineWidth;
// Extension lines from picked object points to the offset dimension line.
[item.start, item.end].forEach((point, index) => {
const target = index === 0 ? line.start : line.end;
ctx.beginPath();
ctx.moveTo(point.x, point.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(line.start.x, line.start.y);
ctx.lineTo(line.end.x, line.end.y);
ctx.stroke();
const tickLength = 18;
const tickAngle = (135 * Math.PI) / 180;
ctx.lineWidth = dimensionLineWidth * 2;
[line.start, line.end].forEach((point) => {
ctx.beginPath();
ctx.moveTo(point.x - Math.cos(tickAngle) * tickLength / 2, point.y - Math.sin(tickAngle) * tickLength / 2);
ctx.lineTo(point.x + Math.cos(tickAngle) * tickLength / 2, point.y + Math.sin(tickAngle) * tickLength / 2);
ctx.stroke();
});
ctx.lineWidth = dimensionLineWidth;
const midX = (line.start.x + line.end.x) / 2;
const midY = (line.start.y + line.end.y) / 2;
ctx.font = '700 21px Helvetica, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const label = item.label || 'Dimension';
const width = ctx.measureText(label).width;
const labelPaddingX = 10;
const labelHeight = 30;
const labelWidth = width + labelPaddingX * 2;
const dx = line.end.x - line.start.x;
const dy = line.end.y - line.start.y;
const lineLength = Math.hypot(dx, dy);
const isMostlyHorizontal = Math.abs(dx) >= Math.abs(dy);
const needsOutsideLabel = isMostlyHorizontal
? lineLength < labelWidth + 28
: lineLength < labelHeight + 28;
let labelX = midX;
let labelY = midY;
if (needsOutsideLabel) {
const leaderGap = 8;
if (isMostlyHorizontal) {
const leftPoint = line.start.x <= line.end.x ? line.start : line.end;
const rightPoint = line.start.x <= line.end.x ? line.end : line.start;
const leftRoom = leftPoint.x;
const rightRoom = ctx.canvas.width - rightPoint.x;
const placeLeft = leftRoom >= labelWidth + leaderGap || leftRoom >= rightRoom;
labelX = placeLeft
? leftPoint.x - leaderGap - labelWidth / 2
: rightPoint.x + leaderGap + labelWidth / 2;
labelY = midY;
const labelEdgeX = placeLeft ? labelX + labelWidth / 2 : labelX - labelWidth / 2;
ctx.beginPath();
ctx.moveTo(placeLeft ? leftPoint.x : rightPoint.x, midY);
ctx.lineTo(labelEdgeX, labelY);
ctx.stroke();
} else {
const topPoint = line.start.y <= line.end.y ? line.start : line.end;
const bottomPoint = line.start.y <= line.end.y ? line.end : line.start;
const topRoom = topPoint.y;
const bottomRoom = ctx.canvas.height - bottomPoint.y;
const placeTop = topRoom >= labelHeight + leaderGap || topRoom >= bottomRoom;
labelX = midX;
labelY = placeTop
? topPoint.y - leaderGap - labelHeight / 2
: bottomPoint.y + leaderGap + labelHeight / 2;
const labelEdgeY = placeTop ? labelY + labelHeight / 2 : labelY - labelHeight / 2;
ctx.beginPath();
ctx.moveTo(midX, placeTop ? topPoint.y : bottomPoint.y);
ctx.lineTo(labelX, labelEdgeY);
ctx.stroke();
}
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(labelX - labelWidth / 2, labelY - labelHeight / 2, labelWidth, labelHeight);
ctx.fillStyle = color;
ctx.fillText(label, labelX, labelY);
ctx.restore();
}
function getDimensionLine(item) {
if (item.lineStart && item.lineEnd) {
return { start: item.lineStart, end: item.lineEnd };
}
return { start: item.start, end: item.end };
}
function buildOffsetDimension(start, end, offsetPoint, label) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const offset = (offsetPoint.x - mid.x) * nx + (offsetPoint.y - mid.y) * ny;
return {
id: crypto.randomUUID(),
start,
end,
lineStart: { x: start.x + nx * offset, y: start.y + ny * offset },
lineEnd: { x: end.x + nx * offset, y: end.y + ny * offset },
label,
};
}
function renderDimensionCanvas(showGuides) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (baseImage) {
ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
dimensions.forEach(item => drawDimension(ctx, item, item.id === activeDimensionId));
if (showGuides && draftPoints.length > 0) {
ctx.fillStyle = '#000000';
draftPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
}
if (showGuides && draftPoints.length === 2 && draftOffsetPoint) {
drawDimension(ctx, buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension'), true);
}
if (showGuides && boxStart && boxEnd) {
const rect = getRectFromPoints(boxStart, boxEnd);
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.setLineDash([]);
ctx.restore();
}
}
function getRectFromPoints(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return { x, y, w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
function addBoxDimensions(rect) {
const offset = 28;
const groupId = crypto.randomUUID();
const widthDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w / 2, y: rect.y - offset },
boxWidthText || dimensionText || 'Width'
),
group: 'box',
groupId,
};
const heightDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x, y: rect.y + rect.h },
{ x: rect.x - offset, y: rect.y + rect.h / 2 },
boxHeightText || dimensionText || 'Height'
),
group: 'box',
groupId,
};
setDimensions(prev => [...prev, widthDim, heightDim]);
setActiveDimensionId(widthDim.id);
}
function autoDetectBoxDimensions() {
if (!baseImage) return;
const canvas = canvasRef.current;
if (!canvas) return;
const detectCanvas = document.createElement('canvas');
const maxDetectW = 260;
const scale = Math.min(maxDetectW / canvas.width, 1);
detectCanvas.width = Math.max(1, Math.round(canvas.width * scale));
detectCanvas.height = Math.max(1, Math.round(canvas.height * scale));
const detectCtx = detectCanvas.getContext('2d', { willReadFrequently: true });
detectCtx.drawImage(baseImage, 0, 0, detectCanvas.width, detectCanvas.height);
const { width, height } = detectCanvas;
const image = detectCtx.getImageData(0, 0, width, height);
const data = image.data;
const columnCounts = new Uint16Array(width);
const rowCounts = new Uint16Array(height);
let nonWhiteCount = 0;
const marginX = Math.round(width * 0.01);
const marginY = Math.round(height * 0.01);
for (let y = marginY; y < height - marginY; y++) {
for (let x = marginX; x < width - marginX; x++) {
const p = (y * width + x) * 4;
const alpha = data[p + 3];
const red = data[p];
const green = data[p + 1];
const blue = data[p + 2];
const isWhite = red >= 245 && green >= 245 && blue >= 245;
if (alpha > 24 && !isWhite) {
columnCounts[x] += 1;
rowCounts[y] += 1;
nonWhiteCount += 1;
}
}
}
const minColumnHits = Math.max(2, Math.round(height * 0.006));
const minRowHits = Math.max(2, Math.round(width * 0.006));
let whiteBoundsMinX = -1;
let whiteBoundsMaxX = -1;
let whiteBoundsMinY = -1;
let whiteBoundsMaxY = -1;
for (let x = marginX; x < width - marginX; x++) {
if (columnCounts[x] >= minColumnHits) {
whiteBoundsMinX = x;
break;
}
}
for (let x = width - marginX - 1; x >= marginX; x--) {
if (columnCounts[x] >= minColumnHits) {
whiteBoundsMaxX = x;
break;
}
}
for (let y = marginY; y < height - marginY; y++) {
if (rowCounts[y] >= minRowHits) {
whiteBoundsMinY = y;
break;
}
}
for (let y = height - marginY - 1; y >= marginY; y--) {
if (rowCounts[y] >= minRowHits) {
whiteBoundsMaxY = y;
break;
}
}
const whiteBoundsW = whiteBoundsMaxX - whiteBoundsMinX;
const whiteBoundsH = whiteBoundsMaxY - whiteBoundsMinY;
const nonWhiteRatio = nonWhiteCount / ((width - marginX * 2) * (height - marginY * 2));
const hasUsefulWhiteBounds =
whiteBoundsMinX >= 0 &&
whiteBoundsMinY >= 0 &&
whiteBoundsW >= width * 0.03 &&
whiteBoundsH >= height * 0.03 &&
nonWhiteRatio >= 0.0005 &&
nonWhiteRatio <= 0.65 &&
(whiteBoundsW < width * 0.95 || whiteBoundsH < height * 0.95);
if (hasUsefulWhiteBounds) {
const padding = 4;
const rectX = Math.max(0, whiteBoundsMinX / scale - padding);
const rectY = Math.max(0, whiteBoundsMinY / scale - padding);
addBoxDimensions({
x: rectX,
y: rectY,
w: Math.min(canvas.width, whiteBoundsMaxX / scale + padding) - rectX,
h: Math.min(canvas.height, whiteBoundsMaxY / scale + padding) - rectY,
});
return;
}
const luminance = new Float32Array(width * height);
const gradients = [];
for (let i = 0; i < width * height; i++) {
const p = i * 4;
luminance[i] = data[p] * 0.299 + data[p + 1] * 0.587 + data[p + 2] * 0.114;
}
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
gradients.push(Math.abs(luminance[i] - luminance[i + 1]) + Math.abs(luminance[i] - luminance[i + width]));
}
}
if (!gradients.length) return;
const mean = gradients.reduce((sum, value) => sum + value, 0) / gradients.length;
const variance = gradients.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / gradients.length;
const threshold = Math.max(24, Math.min(70, mean + Math.sqrt(variance) * 1.1));
const edges = new Uint8Array(width * height);
const borderX = Math.round(width * 0.03);
const borderY = Math.round(height * 0.03);
for (let y = borderY; y < height - borderY; y++) {
for (let x = borderX; x < width - borderX; x++) {
const i = y * width + x;
const gradient = Math.abs(luminance[i] - luminance[i + 1]) + Math.abs(luminance[i] - luminance[i + width]);
if (gradient >= threshold) edges[i] = 1;
}
}
const visited = new Uint8Array(width * height);
const centerX = width / 2;
const centerY = height / 2;
let best = null;
for (let y = borderY; y < height - borderY; y++) {
for (let x = borderX; x < width - borderX; x++) {
const startIndex = y * width + x;
if (!edges[startIndex] || visited[startIndex]) continue;
const stack = [startIndex];
visited[startIndex] = 1;
let count = 0;
let minX = x;
let maxX = x;
let minY = y;
let maxY = y;
while (stack.length) {
const index = stack.pop();
const px = index % width;
const py = Math.floor(index / width);
count += 1;
minX = Math.min(minX, px);
maxX = Math.max(maxX, px);
minY = Math.min(minY, py);
maxY = Math.max(maxY, py);
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
if (!ox && !oy) continue;
const nx = px + ox;
const ny = py + oy;
if (nx < borderX || nx >= width - borderX || ny < borderY || ny >= height - borderY) continue;
const nextIndex = ny * width + nx;
if (!edges[nextIndex] || visited[nextIndex]) continue;
visited[nextIndex] = 1;
stack.push(nextIndex);
}
}
}
const boxW = maxX - minX;
const boxH = maxY - minY;
if (count < 18 || boxW < width * 0.04 || boxH < height * 0.04) continue;
const boxCenterX = (minX + maxX) / 2;
const boxCenterY = (minY + maxY) / 2;
const centerPenalty = Math.hypot((boxCenterX - centerX) / width, (boxCenterY - centerY) / height) * 120;
const score = count + boxW * boxH * 0.02 - centerPenalty;
if (!best || score > best.score) best = { minX, maxX, minY, maxY, score };
}
}
if (!best) {
window.alert('Could not detect a clear sign/object area. Drag a box manually on the image.');
return;
}
const padding = 10;
const rect = {
x: Math.max(0, best.minX / scale - padding),
y: Math.max(0, best.minY / scale - padding),
w: Math.min(canvas.width, best.maxX / scale + padding) - Math.max(0, best.minX / scale - padding),
h: Math.min(canvas.height, best.maxY / scale + padding) - Math.max(0, best.minY / scale - padding),
};
addBoxDimensions(rect);
}
const findDimensionAtPoint = (point) => {
const distanceToSegment = (pointValue, start, end) => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const lenSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((pointValue.x - start.x) * dx + (pointValue.y - start.y) * dy) / lenSq));
const x = start.x + t * dx;
const y = start.y + t * dy;
return Math.hypot(pointValue.x - x, pointValue.y - y);
};
return dimensions.find(item => {
const line = getDimensionLine(item);
return distanceToSegment(point, line.start, line.end) < 10;
}) || null;
};
const handlePointerDown = (e) => {
e.preventDefault();
const point = getCanvasPos(e);
if (dimensionMode === 'box') {
setBoxStart(point);
setBoxEnd(point);
setActiveDimensionId(null);
return;
}
if (draftPoints.length === 2) {
setIsPlacingOffset(true);
setDraftOffsetPoint(point);
return;
}
const existing = findDimensionAtPoint(point);
if (existing) {
setActiveDimensionId(existing.id);
setDimensionText(existing.label || '');
return;
}
setDraftPoints(prev => prev.length >= 2 ? [point] : [...prev, point]);
setDraftOffsetPoint(null);
setActiveDimensionId(null);
};
const handlePointerMove = (e) => {
if (dimensionMode === 'box' && boxStart) {
e.preventDefault();
setBoxEnd(getCanvasPos(e));
return;
}
if (!isPlacingOffset || draftPoints.length !== 2) return;
e.preventDefault();
setDraftOffsetPoint(getCanvasPos(e));
};
const handlePointerUp = () => {
if (dimensionMode === 'box') {
if (!boxStart || !boxEnd) return;
const rect = getRectFromPoints(boxStart, boxEnd);
if (rect.w < 8 || rect.h < 8) {
setBoxStart(null);
setBoxEnd(null);
return;
}
addBoxDimensions(rect);
setBoxStart(null);
setBoxEnd(null);
return;
}
if (!isPlacingOffset || draftPoints.length !== 2 || !draftOffsetPoint) return;
if (Math.hypot(draftPoints[1].x - draftPoints[0].x, draftPoints[1].y - draftPoints[0].y) < 8) {
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
return;
}
const label = dimensionText || 'Dimension';
const next = buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, label);
setDimensions(prev => [...prev, next]);
setActiveDimensionId(next.id);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
};
const updateActiveLabel = () => {
if (!activeDimensionId) return;
setDimensions(prev => prev.map(item => item.id === activeDimensionId ? { ...item, label: dimensionText || 'Dimension' } : item));
};
const removeActiveDimension = () => {
if (!activeDimensionId) return;
const selected = dimensions.find(item => item.id === activeDimensionId);
setDimensions(prev => {
if (selected?.group === 'box' && selected.groupId) {
return prev.filter(item => item.groupId !== selected.groupId);
}
return prev.filter(item => item.id !== activeDimensionId);
});
setActiveDimensionId(null);
setDimensionText('');
};
const switchDimensionMode = (mode) => {
setDimensionMode(mode);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
const handleApply = () => {
renderDimensionCanvas(false);
canvasRef.current.toBlob(blob => {
onApply(new File([blob], 'sign-detail-dimensions.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.92);
requestAnimationFrame(() => renderDimensionCanvas(true));
};
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Add line dimensions or drag a box to auto-place width and height callouts.</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
</div>
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{[
['line', 'Line'],
['box', 'Box'],
].map(([mode, label]) => (
<button
key={mode}
className={`btn btn-sm ${dimensionMode === mode ? 'btn-primary' : 'btn-outline'}`}
style={{ border: 'none', borderRadius: 0 }}
onClick={() => switchDimensionMode(mode)}
>
{label}
</button>
))}
</div>
<input
type="text"
value={dimensionText}
onChange={e => setDimensionText(e.target.value)}
placeholder='e.g. 48" W, 24" H, 8 ft'
style={{ minHeight: 32, margin: 0, width: 220 }}
/>
{dimensionMode === 'box' && (
<>
<input
type="text"
value={boxWidthText}
onChange={e => setBoxWidthText(e.target.value)}
placeholder='Width e.g. 48"'
style={{ minHeight: 32, margin: 0, width: 130 }}
/>
<input
type="text"
value={boxHeightText}
onChange={e => setBoxHeightText(e.target.value)}
placeholder='Height e.g. 24"'
style={{ minHeight: 32, margin: 0, width: 130 }}
/>
<button className="btn btn-outline btn-sm" onClick={autoDetectBoxDimensions} disabled={!loaded || !baseImage}>Auto Box</button>
</>
)}
<button className="btn btn-outline btn-sm" onClick={updateActiveLabel} disabled={!activeDimension}>Update Selected</button>
<button className="btn btn-outline btn-sm" onClick={removeActiveDimension} disabled={!activeDimension}>Remove Selected</button>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{dimensionMode === 'box'
? 'Drag a box around the artwork/sign area to auto-add width and height.'
: (
<>
{draftPoints.length === 0 && 'Click first point.'}
{draftPoints.length === 1 && 'Click second point.'}
{draftPoints.length === 2 && 'Drag outward to place the dimension line.'}
{draftPoints.length === 0 && ' Click an existing dimension to edit it.'}
</>
)}
</span>
</div>
<div style={{ overflow: 'auto', flex: 1, background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>}
<canvas
ref={canvasRef}
style={{ display: loaded ? 'block' : 'none', cursor: 'crosshair', maxWidth: '100%', touchAction: 'none', userSelect: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
</div>
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 8, flexShrink: 0 }}>
<button className="btn btn-outline btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sm" onClick={handleApply}>Apply to Sign Detail Photo</button>
</div>
</div>
</div>
);
}
// ─── Artwork Mockup Editor ───────────────────────────────────────────────────
const HANDLE_SIZE = 12;
function makeArtworkPoints(x, y, w, h) {
return [
{ x, y },
{ x: x + w, y },
{ x: x + w, y: y + h },
{ x, y: y + h },
];
}
function cloneArtworkState(items) {
return items.map(item => ({
...item,
points: item.points.map(point => ({ ...point })),
}));
}
function getArtworkBounds(points) {
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
function getArtworkHandles(artwork) {
const labels = ['nw', 'ne', 'se', 'sw'];
return artwork.points.map((point, index) => ({ ...point, id: labels[index], index }));
}
function getRotateHandle(artwork) {
const [topLeft, topRight] = artwork.points;
const midX = (topLeft.x + topRight.x) / 2;
const midY = (topLeft.y + topRight.y) / 2;
const dx = topRight.x - topLeft.x;
const dy = topRight.y - topLeft.y;
const length = Math.hypot(dx, dy) || 1;
return {
x: midX - (dy / length) * 34,
y: midY + (dx / length) * 34,
};
}
function pointInPolygon(point, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersects = ((yi > point.y) !== (yj > point.y)) &&
(point.x < ((xj - xi) * (point.y - yi)) / (yj - yi || 1) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function interpolateQuad(points, u, v) {
const [p0, p1, p2, p3] = points;
return {
x: (1 - u) * (1 - v) * p0.x + u * (1 - v) * p1.x + u * v * p2.x + (1 - u) * v * p3.x,
y: (1 - u) * (1 - v) * p0.y + u * (1 - v) * p1.y + u * v * p2.y + (1 - u) * v * p3.y,
};
}
function rotatePoints(points, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const center = points.reduce((acc, point) => ({ x: acc.x + point.x / points.length, y: acc.y + point.y / points.length }), { x: 0, y: 0 });
return points.map(point => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return {
x: center.x + dx * cos - dy * sin,
y: center.y + dx * sin + dy * cos,
};
});
}
function drawTriangleImage(ctx, img, s0, s1, s2, d0, d1, d2) {
const denom = s0.x * (s1.y - s2.y) + s1.x * (s2.y - s0.y) + s2.x * (s0.y - s1.y);
if (!denom) return;
const a = (d0.x * (s1.y - s2.y) + d1.x * (s2.y - s0.y) + d2.x * (s0.y - s1.y)) / denom;
const c = (d0.x * (s2.x - s1.x) + d1.x * (s0.x - s2.x) + d2.x * (s1.x - s0.x)) / denom;
const e = (d0.x * (s1.x * s2.y - s2.x * s1.y) + d1.x * (s2.x * s0.y - s0.x * s2.y) + d2.x * (s0.x * s1.y - s1.x * s0.y)) / denom;
const b = (d0.y * (s1.y - s2.y) + d1.y * (s2.y - s0.y) + d2.y * (s0.y - s1.y)) / denom;
const d = (d0.y * (s2.x - s1.x) + d1.y * (s0.x - s2.x) + d2.y * (s1.x - s0.x)) / denom;
const f = (d0.y * (s1.x * s2.y - s2.x * s1.y) + d1.y * (s2.x * s0.y - s0.x * s2.y) + d2.y * (s0.x * s1.y - s1.x * s0.y)) / denom;
ctx.save();
ctx.beginPath();
ctx.moveTo(d0.x, d0.y);
ctx.lineTo(d1.x, d1.y);
ctx.lineTo(d2.x, d2.y);
ctx.closePath();
ctx.clip();
ctx.transform(a, b, c, d, e, f);
ctx.drawImage(img, 0, 0);
ctx.restore();
}
function drawImageInQuad(ctx, img, points) {
const steps = 10;
for (let y = 0; y < steps; y += 1) {
for (let x = 0; x < steps; x += 1) {
const u0 = x / steps;
const u1 = (x + 1) / steps;
const v0 = y / steps;
const v1 = (y + 1) / steps;
const d00 = interpolateQuad(points, u0, v0);
const d10 = interpolateQuad(points, u1, v0);
const d11 = interpolateQuad(points, u1, v1);
const d01 = interpolateQuad(points, u0, v1);
const s00 = { x: u0 * img.naturalWidth, y: v0 * img.naturalHeight };
const s10 = { x: u1 * img.naturalWidth, y: v0 * img.naturalHeight };
const s11 = { x: u1 * img.naturalWidth, y: v1 * img.naturalHeight };
const s01 = { x: u0 * img.naturalWidth, y: v1 * img.naturalHeight };
drawTriangleImage(ctx, img, s00, s10, s11, d00, d10, d11);
drawTriangleImage(ctx, img, s00, s11, s01, d00, d11, d01);
}
}
}
function PhotoEditorModal({
sourceImage,
onApply,
onCancel,
title = 'Sign Mockup Editor',
subtitle = 'Import artwork, add dimensions, then save the edited photo.',
applyLabel = 'Apply Edited Photo',
outputName = 'sign-mockup.jpg',
downloadPrefix = 'sign-mockup',
targets = null,
}) {
const canvasRef = useRef(null);
const artworkInputRef = useRef(null);
const dragRef = useRef(null);
const layerDragRef = useRef(null);
const [baseImage, setBaseImage] = useState(null);
const [artworks, setArtworks] = useState([]);
const [dimensions, setDimensions] = useState([]);
const [history, setHistory] = useState([[]]);
const [historyIndex, setHistoryIndex] = useState(0);
const [activeTool, setActiveTool] = useState('select');
const [selectedArtworkId, setSelectedArtworkId] = useState(null);
const [activeDimensionId, setActiveDimensionId] = useState(null);
const [loaded, setLoaded] = useState(false);
const [calibrating, setCalibrating] = useState(false);
const [calibrationPoints, setCalibrationPoints] = useState([]);
const [calibrationDistance, setCalibrationDistance] = useState('');
const [sizeUnit, setSizeUnit] = useState('in');
const [artworkWidth, setArtworkWidth] = useState('');
const [artworkHeight, setArtworkHeight] = useState('');
const [lockRatio, setLockRatio] = useState(true);
const [draftPoints, setDraftPoints] = useState([]);
const [draftOffsetPoint, setDraftOffsetPoint] = useState(null);
const [isPlacingOffset, setIsPlacingOffset] = useState(false);
const [boxStart, setBoxStart] = useState(null);
const [boxEnd, setBoxEnd] = useState(null);
const [dimensionText, setDimensionText] = useState('');
const [boxWidthText, setBoxWidthText] = useState('');
const [boxHeightText, setBoxHeightText] = useState('');
const [activeTargetId, setActiveTargetId] = useState(targets?.[0]?.id || 'single');
const [targetSources, setTargetSources] = useState(() => Object.fromEntries((targets || []).map(target => [target.id, target.sourceImage || ''])));
const activeTarget = targets?.find(target => target.id === activeTargetId) || null;
const effectiveSourceImage = activeTarget ? targetSources[activeTarget.id] : sourceImage;
const effectiveApplyLabel = activeTarget?.applyLabel || applyLabel;
const effectiveOutputName = activeTarget?.outputName || outputName;
const effectiveDownloadPrefix = activeTarget?.downloadPrefix || downloadPrefix;
const selectedArtwork = artworks.find(item => item.id === selectedArtworkId) || null;
const activeDimension = dimensions.find(item => item.id === activeDimensionId) || null;
const commitArtworks = (updater) => {
setArtworks(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater;
setHistory(current => {
const trimmed = current.slice(0, historyIndex + 1);
return [...trimmed, cloneArtworkState(next)].slice(-40);
});
setHistoryIndex(index => Math.min(index + 1, 39));
return next;
});
};
const setArtworksLive = (updater) => {
setArtworks(prev => typeof updater === 'function' ? updater(prev) : updater);
};
const resetEditorLayers = () => {
setArtworks([]);
setDimensions([]);
setHistory([[]]);
setHistoryIndex(0);
setSelectedArtworkId(null);
setActiveDimensionId(null);
setCalibrationPoints([]);
setCalibrating(false);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
useEffect(() => {
if (!targets) return;
setTargetSources(Object.fromEntries(targets.map(target => [target.id, target.sourceImage || ''])));
}, [targets]);
useEffect(() => {
resetEditorLayers();
}, [activeTargetId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const initBlank = () => {
canvas.width = 800;
canvas.height = 500;
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
setBaseImage(null);
setLoaded(true);
};
setLoaded(false);
if (!effectiveSourceImage) { initBlank(); return; }
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const MAX_W = 960;
const MAX_H = 580;
const scale = Math.min(MAX_W / img.naturalWidth, MAX_H / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
setBaseImage(img);
setLoaded(true);
};
img.onerror = initBlank;
img.src = effectiveSourceImage;
}, [effectiveSourceImage]);
useEffect(() => {
renderCanvas(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImage, artworks, dimensions, selectedArtworkId, activeDimensionId, calibrationPoints, draftPoints, draftOffsetPoint, boxStart, boxEnd]);
const getCanvasPos = (event) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = event.touches ? event.touches[0] : event;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY,
};
};
function getDimensionLine(item) {
if (item.lineStart && item.lineEnd) return { start: item.lineStart, end: item.lineEnd };
return { start: item.start, end: item.end };
}
function buildOffsetDimension(start, end, offsetPoint, label) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const offset = (offsetPoint.x - mid.x) * nx + (offsetPoint.y - mid.y) * ny;
return {
id: crypto.randomUUID(),
start,
end,
lineStart: { x: start.x + nx * offset, y: start.y + ny * offset },
lineEnd: { x: end.x + nx * offset, y: end.y + ny * offset },
label,
};
}
function drawDimension(ctx, item, selected = false) {
const color = '#000000';
const line = getDimensionLine(item);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
const dimensionLineWidth = selected ? 3 : 2.25;
ctx.lineWidth = dimensionLineWidth;
[item.start, item.end].forEach((point, index) => {
const target = index === 0 ? line.start : line.end;
ctx.beginPath();
ctx.moveTo(point.x, point.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(line.start.x, line.start.y);
ctx.lineTo(line.end.x, line.end.y);
ctx.stroke();
const tickLength = 18;
const tickAngle = (135 * Math.PI) / 180;
ctx.lineWidth = dimensionLineWidth * 2;
[line.start, line.end].forEach((point) => {
ctx.beginPath();
ctx.moveTo(point.x - Math.cos(tickAngle) * tickLength / 2, point.y - Math.sin(tickAngle) * tickLength / 2);
ctx.lineTo(point.x + Math.cos(tickAngle) * tickLength / 2, point.y + Math.sin(tickAngle) * tickLength / 2);
ctx.stroke();
});
ctx.lineWidth = dimensionLineWidth;
const midX = (line.start.x + line.end.x) / 2;
const midY = (line.start.y + line.end.y) / 2;
ctx.font = '700 21px Helvetica, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const label = item.label || 'Dimension';
const width = ctx.measureText(label).width;
const labelPaddingX = 10;
const labelHeight = 30;
const labelWidth = width + labelPaddingX * 2;
const dx = line.end.x - line.start.x;
const dy = line.end.y - line.start.y;
const lineLength = Math.hypot(dx, dy);
const isMostlyHorizontal = Math.abs(dx) >= Math.abs(dy);
const needsOutsideLabel = isMostlyHorizontal ? lineLength < labelWidth + 28 : lineLength < labelHeight + 28;
let labelX = midX;
let labelY = midY;
if (needsOutsideLabel) {
const leaderGap = 8;
if (isMostlyHorizontal) {
const leftPoint = line.start.x <= line.end.x ? line.start : line.end;
const rightPoint = line.start.x <= line.end.x ? line.end : line.start;
const placeLeft = leftPoint.x >= labelWidth + leaderGap || leftPoint.x >= ctx.canvas.width - rightPoint.x;
labelX = placeLeft ? leftPoint.x - leaderGap - labelWidth / 2 : rightPoint.x + leaderGap + labelWidth / 2;
labelY = midY;
ctx.beginPath();
ctx.moveTo(placeLeft ? leftPoint.x : rightPoint.x, midY);
ctx.lineTo(placeLeft ? labelX + labelWidth / 2 : labelX - labelWidth / 2, labelY);
ctx.stroke();
} else {
const topPoint = line.start.y <= line.end.y ? line.start : line.end;
const bottomPoint = line.start.y <= line.end.y ? line.end : line.start;
const placeTop = topPoint.y >= labelHeight + leaderGap || topPoint.y >= ctx.canvas.height - bottomPoint.y;
labelX = midX;
labelY = placeTop ? topPoint.y - leaderGap - labelHeight / 2 : bottomPoint.y + leaderGap + labelHeight / 2;
ctx.beginPath();
ctx.moveTo(midX, placeTop ? topPoint.y : bottomPoint.y);
ctx.lineTo(labelX, placeTop ? labelY + labelHeight / 2 : labelY - labelHeight / 2);
ctx.stroke();
}
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(labelX - labelWidth / 2, labelY - labelHeight / 2, labelWidth, labelHeight);
ctx.fillStyle = color;
ctx.fillText(label, labelX, labelY);
ctx.restore();
}
function renderCanvas(showGuides) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (baseImage) {
ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
artworks.forEach((item) => {
const alpha = item.opacity == null ? 1 : item.opacity;
ctx.save();
ctx.globalAlpha = alpha;
drawImageInQuad(ctx, item.img, item.points);
ctx.restore();
if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = '#f5a523';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
item.points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.closePath();
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
const topMid = { x: (item.points[0].x + item.points[1].x) / 2, y: (item.points[0].y + item.points[1].y) / 2 };
ctx.moveTo(topMid.x, topMid.y);
ctx.lineTo(rotateHandle.x, rotateHandle.y);
ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#f5a523';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
ctx.fill();
ctx.stroke();
});
ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = '#f5a523';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f5a523';
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
}
});
dimensions.forEach(item => drawDimension(ctx, item, item.id === activeDimensionId));
if (showGuides && draftPoints.length > 0) {
ctx.fillStyle = '#000000';
draftPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
}
if (showGuides && draftPoints.length === 2 && draftOffsetPoint) {
drawDimension(ctx, buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension'), true);
}
if (showGuides && boxStart && boxEnd) {
const rect = getRectFromPoints(boxStart, boxEnd);
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.setLineDash([]);
ctx.restore();
}
if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = '#22c55e';
calibrationPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
if (calibrationPoints.length === 2) {
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
ctx.lineTo(calibrationPoints[1].x, calibrationPoints[1].y);
ctx.stroke();
}
}
}
const hitHandle = (point, item = selectedArtwork) => {
if (!item) return null;
return getArtworkHandles(item).find(handle =>
Math.abs(point.x - handle.x) <= HANDLE_SIZE && Math.abs(point.y - handle.y) <= HANDLE_SIZE
) || null;
};
const hitRotateHandle = (point, item = selectedArtwork) => {
if (!item) return false;
const handle = getRotateHandle(item);
return Math.hypot(point.x - handle.x, point.y - handle.y) <= HANDLE_SIZE * 1.4;
};
const findArtworkAtPoint = (point) => {
for (let i = artworks.length - 1; i >= 0; i -= 1) {
if (pointInPolygon(point, artworks[i].points)) return artworks[i];
}
return null;
};
const pxPerInch = (() => {
if (calibrationPoints.length !== 2 || !Number(calibrationDistance)) return null;
const [a, b] = calibrationPoints;
const px = Math.hypot(b.x - a.x, b.y - a.y);
return px / Number(calibrationDistance);
})();
function getRectFromPoints(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return { x, y, w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
function addBoxDimensions(rect) {
const offset = 28;
const groupId = crypto.randomUUID();
const widthDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w / 2, y: rect.y - offset },
boxWidthText || dimensionText || 'Width'
),
group: 'box',
groupId,
};
const heightDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x, y: rect.y + rect.h },
{ x: rect.x - offset, y: rect.y + rect.h / 2 },
boxHeightText || dimensionText || 'Height'
),
group: 'box',
groupId,
};
setDimensions(prev => [...prev, widthDim, heightDim]);
setActiveDimensionId(widthDim.id);
setSelectedArtworkId(null);
}
const autoDimensionSelectedArtwork = () => {
if (!selectedArtwork) return;
const bounds = getArtworkBounds(selectedArtwork.points);
if (bounds.w < 8 || bounds.h < 8) return;
addBoxDimensions(bounds);
};
const findDimensionAtPoint = (point) => {
const distanceToSegment = (pointValue, start, end) => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const lenSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((pointValue.x - start.x) * dx + (pointValue.y - start.y) * dy) / lenSq));
const x = start.x + t * dx;
const y = start.y + t * dy;
return Math.hypot(pointValue.x - x, pointValue.y - y);
};
return dimensions.find(item => {
const line = getDimensionLine(item);
return distanceToSegment(point, line.start, line.end) < 12;
}) || null;
};
const updateActiveDimensionLabel = () => {
if (!activeDimensionId) return;
setDimensions(prev => prev.map(item => item.id === activeDimensionId ? { ...item, label: dimensionText || 'Dimension' } : item));
};
const removeActiveDimension = () => {
if (!activeDimensionId) return;
const selected = dimensions.find(item => item.id === activeDimensionId);
setDimensions(prev => {
if (selected?.group === 'box' && selected.groupId) return prev.filter(item => item.groupId !== selected.groupId);
return prev.filter(item => item.id !== activeDimensionId);
});
setActiveDimensionId(null);
setDimensionText('');
};
const resetDimensionDraft = () => {
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
const autoDetectBoxDimensions = () => {
if (!baseImage) return;
const canvas = canvasRef.current;
if (!canvas) return;
const detectCanvas = document.createElement('canvas');
const maxDetectW = 300;
const scale = Math.min(maxDetectW / canvas.width, 1);
detectCanvas.width = Math.max(1, Math.round(canvas.width * scale));
detectCanvas.height = Math.max(1, Math.round(canvas.height * scale));
const detectCtx = detectCanvas.getContext('2d', { willReadFrequently: true });
detectCtx.drawImage(baseImage, 0, 0, detectCanvas.width, detectCanvas.height);
const { width, height } = detectCanvas;
const { data } = detectCtx.getImageData(0, 0, width, height);
const columnCounts = new Uint16Array(width);
const rowCounts = new Uint16Array(height);
const marginX = Math.round(width * 0.01);
const marginY = Math.round(height * 0.01);
let nonWhiteCount = 0;
for (let y = marginY; y < height - marginY; y++) {
for (let x = marginX; x < width - marginX; x++) {
const p = (y * width + x) * 4;
const isWhite = data[p] >= 245 && data[p + 1] >= 245 && data[p + 2] >= 245;
if (data[p + 3] > 24 && !isWhite) {
columnCounts[x] += 1;
rowCounts[y] += 1;
nonWhiteCount += 1;
}
}
}
const minColumnHits = Math.max(2, Math.round(height * 0.006));
const minRowHits = Math.max(2, Math.round(width * 0.006));
let minX = -1;
let maxX = -1;
let minY = -1;
let maxY = -1;
for (let x = marginX; x < width - marginX; x++) if (columnCounts[x] >= minColumnHits) { minX = x; break; }
for (let x = width - marginX - 1; x >= marginX; x--) if (columnCounts[x] >= minColumnHits) { maxX = x; break; }
for (let y = marginY; y < height - marginY; y++) if (rowCounts[y] >= minRowHits) { minY = y; break; }
for (let y = height - marginY - 1; y >= marginY; y--) if (rowCounts[y] >= minRowHits) { maxY = y; break; }
const boundsW = maxX - minX;
const boundsH = maxY - minY;
const nonWhiteRatio = nonWhiteCount / ((width - marginX * 2) * (height - marginY * 2));
if (minX < 0 || minY < 0 || boundsW < width * 0.03 || boundsH < height * 0.03 || nonWhiteRatio > 0.65) {
window.alert('Could not detect a clear non-white object. Drag a box manually.');
return;
}
const padding = 4;
const rectX = Math.max(0, minX / scale - padding);
const rectY = Math.max(0, minY / scale - padding);
addBoxDimensions({
x: rectX,
y: rectY,
w: Math.min(canvas.width, maxX / scale + padding) - rectX,
h: Math.min(canvas.height, maxY / scale + padding) - rectY,
});
};
const handleArtworkFiles = (fileList) => {
const files = Array.from(fileList || []).filter(isPhotoFile);
if (!files.length) {
alert('Please upload image artwork.');
return;
}
files.forEach((file, fileIndex) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = canvasRef.current;
const maxW = canvas.width * 0.35;
const maxH = canvas.height * 0.35;
const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
const w = Math.max(40, img.naturalWidth * scale);
const h = Math.max(40, img.naturalHeight * scale);
const id = crypto.randomUUID();
const offset = (artworks.length + fileIndex) * 18;
const x = (canvas.width - w) / 2 + offset;
const y = (canvas.height - h) / 2 + offset;
const nextArtwork = {
id,
name: file.name,
img,
points: makeArtworkPoints(x, y, w, h),
ratio: img.naturalWidth / img.naturalHeight,
opacity: 1,
};
commitArtworks(prev => [...prev, nextArtwork]);
setSelectedArtworkId(id);
setArtworkWidth('');
setArtworkHeight('');
};
img.onerror = () => {
URL.revokeObjectURL(url);
alert('Could not load that artwork file.');
};
img.src = url;
});
};
const applySpecifiedSize = () => {
if (!selectedArtwork) return;
const widthValue = Number(artworkWidth);
const heightValue = Number(artworkHeight);
if (!widthValue && !heightValue) return;
if (sizeUnit === 'in' && !pxPerInch) {
alert('Calibrate the photo first, or switch the size unit to pixels.');
return;
}
const multiplier = sizeUnit === 'in' ? pxPerInch : 1;
const bounds = getArtworkBounds(selectedArtwork.points);
let nextW = widthValue ? widthValue * multiplier : bounds.w;
let nextH = heightValue ? heightValue * multiplier : bounds.h;
if (lockRatio && widthValue && !heightValue) nextH = nextW / selectedArtwork.ratio;
if (lockRatio && heightValue && !widthValue) nextW = nextH * selectedArtwork.ratio;
if (lockRatio && widthValue && heightValue) nextH = nextW / selectedArtwork.ratio;
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
commitArtworks(prev => prev.map(item => item.id === selectedArtwork.id
? { ...item, points: makeArtworkPoints(centerX - nextW / 2, centerY - nextH / 2, Math.max(10, nextW), Math.max(10, nextH)) }
: item
));
};
const removeSelectedArtwork = () => {
if (!selectedArtworkId) return;
commitArtworks(prev => {
const next = prev.filter(item => item.id !== selectedArtworkId);
setSelectedArtworkId(next[next.length - 1]?.id || null);
return next;
});
};
const duplicateSelectedArtwork = () => {
if (!selectedArtwork) return;
const id = crypto.randomUUID();
const copy = {
...selectedArtwork,
id,
name: `${selectedArtwork.name || 'Artwork'} copy`,
points: selectedArtwork.points.map(point => ({ x: point.x + 18, y: point.y + 18 })),
};
commitArtworks(prev => [...prev, copy]);
setSelectedArtworkId(id);
};
const reorderLayer = (dragId, targetId) => {
if (!dragId || !targetId || dragId === targetId) return;
commitArtworks(prev => {
const next = [...prev];
const from = next.findIndex(item => item.id === dragId);
const to = next.findIndex(item => item.id === targetId);
if (from < 0 || to < 0) return prev;
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
const updateSelectedArtwork = (changes) => {
if (!selectedArtworkId) return;
commitArtworks(prev => prev.map(item => item.id === selectedArtworkId ? { ...item, ...changes } : item));
};
const nudgeSelectedArtwork = (dx, dy) => {
if (!selectedArtworkId) return;
commitArtworks(prev => prev.map(item => item.id === selectedArtworkId
? { ...item, points: item.points.map(point => ({ x: point.x + dx, y: point.y + dy })) }
: item
));
};
const undo = () => {
if (historyIndex <= 0) return;
const nextIndex = historyIndex - 1;
const next = cloneArtworkState(history[nextIndex] || []);
setHistoryIndex(nextIndex);
setArtworks(next);
setSelectedArtworkId(current => next.some(item => item.id === current) ? current : next[next.length - 1]?.id || null);
};
const redo = () => {
if (historyIndex >= history.length - 1) return;
const nextIndex = historyIndex + 1;
const next = cloneArtworkState(history[nextIndex] || []);
setHistoryIndex(nextIndex);
setArtworks(next);
setSelectedArtworkId(current => next.some(item => item.id === current) ? current : next[next.length - 1]?.id || null);
};
const handlePointerDown = (e) => {
e.preventDefault();
const point = getCanvasPos(e);
if (activeTool === 'box') {
setBoxStart(point);
setBoxEnd(point);
setActiveDimensionId(null);
setSelectedArtworkId(null);
return;
}
if (activeTool === 'dimension') {
if (draftPoints.length === 2) {
setIsPlacingOffset(true);
setDraftOffsetPoint(point);
return;
}
const existingDimension = findDimensionAtPoint(point);
if (existingDimension) {
setActiveDimensionId(existingDimension.id);
setSelectedArtworkId(null);
setDimensionText(existingDimension.label || '');
return;
}
setDraftPoints(prev => prev.length >= 2 ? [point] : [...prev, point]);
setDraftOffsetPoint(null);
setActiveDimensionId(null);
setSelectedArtworkId(null);
return;
}
if (calibrating) {
setCalibrationPoints(prev => {
const next = prev.length >= 2 ? [point] : [...prev, point];
if (next.length === 2) setCalibrating(false);
return next;
});
return;
}
const existingDimension = findDimensionAtPoint(point);
if (existingDimension && !findArtworkAtPoint(point)) {
setActiveDimensionId(existingDimension.id);
setSelectedArtworkId(null);
setDimensionText(existingDimension.label || '');
return;
}
const targetArtwork = selectedArtwork && (hitHandle(point, selectedArtwork) || pointInPolygon(point, selectedArtwork.points))
? selectedArtwork
: findArtworkAtPoint(point);
if (!targetArtwork) {
setSelectedArtworkId(null);
setActiveDimensionId(null);
return;
}
setSelectedArtworkId(targetArtwork.id);
setActiveDimensionId(null);
const handle = hitHandle(point, targetArtwork);
if (hitRotateHandle(point, targetArtwork)) {
const center = targetArtwork.points.reduce((acc, p) => ({ x: acc.x + p.x / targetArtwork.points.length, y: acc.y + p.y / targetArtwork.points.length }), { x: 0, y: 0 });
dragRef.current = {
type: 'rotate',
startAngle: Math.atan2(point.y - center.y, point.x - center.x),
center,
initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) },
};
} else if (handle) {
dragRef.current = { type: 'resize', handle, start: point, initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) } };
} else {
dragRef.current = { type: 'move', start: point, initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) } };
}
};
const handlePointerMove = (e) => {
if (activeTool === 'box' && boxStart) {
e.preventDefault();
setBoxEnd(getCanvasPos(e));
return;
}
if (activeTool === 'dimension' && isPlacingOffset && draftPoints.length === 2) {
e.preventDefault();
setDraftOffsetPoint(getCanvasPos(e));
return;
}
if (!dragRef.current) return;
e.preventDefault();
const point = getCanvasPos(e);
const drag = dragRef.current;
if (drag.type === 'move') {
const dx = point.x - drag.start.x;
const dy = point.y - drag.start.y;
const points = drag.initial.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
if (drag.type === 'rotate') {
const currentAngle = Math.atan2(point.y - drag.center.y, point.x - drag.center.x);
const degrees = ((currentAngle - drag.startAngle) * 180) / Math.PI;
const points = rotatePoints(drag.initial.points, degrees);
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
const minSize = 16;
const handleIndex = drag.handle.index;
if (!lockRatio) {
const points = drag.initial.points.map((p, index) => index === handleIndex ? point : { ...p });
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
const oppositeIndex = (handleIndex + 2) % 4;
const anchor = drag.initial.points[oppositeIndex];
const dx = point.x - anchor.x;
const dy = point.y - anchor.y;
let w = Math.max(minSize, Math.abs(dx));
let h = w / drag.initial.ratio;
if (Math.abs(dy) > h) {
h = Math.max(minSize, Math.abs(dy));
w = h * drag.initial.ratio;
}
const x = dx >= 0 ? anchor.x : anchor.x - w;
const y = dy >= 0 ? anchor.y : anchor.y - h;
const points = makeArtworkPoints(x, y, w, h);
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
};
const handlePointerUp = () => {
if (activeTool === 'box') {
if (!boxStart || !boxEnd) return;
const rect = getRectFromPoints(boxStart, boxEnd);
if (rect.w >= 8 && rect.h >= 8) addBoxDimensions(rect);
setBoxStart(null);
setBoxEnd(null);
return;
}
if (activeTool === 'dimension') {
if (!isPlacingOffset || draftPoints.length !== 2 || !draftOffsetPoint) return;
if (Math.hypot(draftPoints[1].x - draftPoints[0].x, draftPoints[1].y - draftPoints[0].y) >= 8) {
const next = buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension');
setDimensions(prev => [...prev, next]);
setActiveDimensionId(next.id);
}
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
return;
}
if (dragRef.current) {
const draggedId = dragRef.current.initial.id;
setArtworks(prev => {
setHistory(current => {
const trimmed = current.slice(0, historyIndex + 1);
return [...trimmed, cloneArtworkState(prev)].slice(-40);
});
setHistoryIndex(index => Math.min(index + 1, 39));
setSelectedArtworkId(draggedId);
return prev;
});
}
dragRef.current = null;
};
const handleArtworkDrop = (e) => {
e.preventDefault();
handleArtworkFiles(e.dataTransfer.files);
};
const handleApply = () => {
renderCanvas(false);
canvasRef.current.toBlob(blob => {
if (!blob) return;
const file = new File([blob], effectiveOutputName, { type: 'image/jpeg' });
if (activeTarget) {
setTargetSources(prev => ({ ...prev, [activeTarget.id]: URL.createObjectURL(blob) }));
onApply(file, activeTarget.id);
} else {
onApply(file);
}
}, 'image/jpeg', 0.92);
requestAnimationFrame(() => renderCanvas(true));
};
const saveAsJpg = () => {
renderCanvas(false);
canvasRef.current.toBlob(blob => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${effectiveDownloadPrefix}-${new Date().toISOString().slice(0, 10)}.jpg`;
a.click();
URL.revokeObjectURL(url);
requestAnimationFrame(() => renderCanvas(true));
}, 'image/jpeg', 0.92);
};
useEffect(() => {
const handleKeyDown = (event) => {
if (!selectedArtworkId) return;
const target = event.target;
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
const step = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowUp') { event.preventDefault(); nudgeSelectedArtwork(0, -step); }
if (event.key === 'ArrowDown') { event.preventDefault(); nudgeSelectedArtwork(0, step); }
if (event.key === 'ArrowLeft') { event.preventDefault(); nudgeSelectedArtwork(-step, 0); }
if (event.key === 'ArrowRight') { event.preventDefault(); nudgeSelectedArtwork(step, 0); }
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'y') {
event.preventDefault();
redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedArtworkId, historyIndex, history]);
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.72)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999, padding: 20,
}}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{
background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column',
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
}}>
{/* Header */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{title}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{subtitle}</div>
</div>
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}></button>
</div>
{targets && (
<div style={{ padding: '10px 18px 0', display: 'flex', gap: 8, flexShrink: 0 }}>
{targets.map(target => (
<button
key={target.id}
type="button"
className={`btn btn-sm ${target.id === activeTargetId ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setActiveTargetId(target.id)}
>
{target.label}
</button>
))}
<span style={{ alignSelf: 'center', fontSize: 11, color: 'var(--text-muted)' }}>
Apply saves the current tab, then you can switch tabs without closing.
</span>
</div>
)}
{/* Toolbar */}
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
{[
['select', 'Select'],
['dimension', 'Line Dim'],
['box', 'Box Dim'],
].map(([tool, label]) => (
<button
key={tool}
className={`btn btn-sm ${activeTool === tool ? 'btn-primary' : 'btn-outline'}`}
style={{ border: 'none', borderRadius: 0 }}
onClick={() => { setActiveTool(tool); resetDimensionDraft(); setCalibrating(false); }}
>
{label}
</button>
))}
</div>
<button className="btn btn-primary btn-sm" onClick={() => artworkInputRef.current?.click()}>Import Artwork</button>
<input
ref={artworkInputRef}
type="file"
accept={PHOTO_FILE_ACCEPT}
multiple
style={{ display: 'none' }}
onChange={e => { if (e.target.files.length) handleArtworkFiles(e.target.files); e.target.value = ''; }}
/>
<button className="btn btn-outline btn-sm" onClick={undo} disabled={historyIndex <= 0}>Undo</button>
<button className="btn btn-outline btn-sm" onClick={redo} disabled={historyIndex >= history.length - 1}>Redo</button>
<button className="btn btn-outline btn-sm" onClick={removeSelectedArtwork} disabled={!selectedArtwork}>Remove Artwork</button>
<button className="btn btn-outline btn-sm" onClick={duplicateSelectedArtwork} disabled={!selectedArtwork}>Duplicate</button>
<button className="btn btn-outline btn-sm" onClick={autoDimensionSelectedArtwork} disabled={!selectedArtwork}>Auto Dim Artwork</button>
<input
type="text"
value={dimensionText}
onChange={e => setDimensionText(e.target.value)}
placeholder='Dimension text'
style={{ minHeight: 32, margin: 0, width: 145 }}
/>
{activeTool === 'box' && (
<>
<input
type="text"
value={boxWidthText}
onChange={e => setBoxWidthText(e.target.value)}
placeholder='Width'
style={{ minHeight: 32, margin: 0, width: 90 }}
/>
<input
type="text"
value={boxHeightText}
onChange={e => setBoxHeightText(e.target.value)}
placeholder='Height'
style={{ minHeight: 32, margin: 0, width: 90 }}
/>
<button className="btn btn-outline btn-sm" onClick={autoDetectBoxDimensions} disabled={!loaded || !baseImage}>Auto Box</button>
</>
)}
<button className="btn btn-outline btn-sm" onClick={updateActiveDimensionLabel} disabled={!activeDimension}>Update Dim</button>
<button className="btn btn-outline btn-sm" onClick={removeActiveDimension} disabled={!activeDimension}>Remove Dim</button>
<button className={`btn btn-sm ${calibrating ? 'btn-primary' : 'btn-outline'}`} onClick={() => { setCalibrating(true); setCalibrationPoints([]); }}>
Pick Scale Points
</button>
<input
type="number"
min="0"
step="0.01"
placeholder="Real distance"
value={calibrationDistance}
onChange={e => setCalibrationDistance(e.target.value)}
style={{ width: 110, minHeight: 32, margin: 0 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>inches</span>
<div style={{ width: 1, height: 28, background: 'var(--border)' }} />
<input
type="number"
min="0"
step="0.01"
placeholder="Artwork W"
value={artworkWidth}
onChange={e => setArtworkWidth(e.target.value)}
style={{ width: 100, minHeight: 32, margin: 0 }}
/>
<input
type="number"
min="0"
step="0.01"
placeholder="Artwork H"
value={artworkHeight}
onChange={e => setArtworkHeight(e.target.value)}
style={{ width: 100, minHeight: 32, margin: 0 }}
/>
<select value={sizeUnit} onChange={e => setSizeUnit(e.target.value)} style={{ width: 72, minHeight: 32, margin: 0 }}>
<option value="in">in</option>
<option value="px">px</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-muted)' }}>
<input type="checkbox" checked={lockRatio} onChange={e => setLockRatio(e.target.checked)} style={{ width: 14, height: 14, margin: 0 }} />
Lock ratio
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-muted)' }}>
Opacity
<input
type="range"
min="0.1"
max="1"
step="0.05"
value={selectedArtwork?.opacity ?? 1}
onChange={e => updateSelectedArtwork({ opacity: Number(e.target.value) })}
disabled={!selectedArtwork}
style={{ width: 90 }}
/>
</label>
<button className="btn btn-outline btn-sm" onClick={applySpecifiedSize} disabled={!selectedArtwork}>Apply Size</button>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{pxPerInch ? `${pxPerInch.toFixed(1)} px/in` : calibrating ? 'Click two points on a known-size item' : 'Drop artwork here, drag layers, lock ratio for normal resize, unlock for free transform'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '220px minmax(0, 1fr)', minHeight: 0, flex: 1 }}>
<div style={{ borderRight: '1px solid var(--border)', background: 'var(--card-bg)', padding: 12, overflowY: 'auto' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
{dimensions.length > 0 && (
<>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{dimensions.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => {
setActiveDimensionId(item.id);
setSelectedArtworkId(null);
setDimensionText(item.label || '');
setActiveTool('select');
}}
style={{
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
padding: '8px 10px',
cursor: 'pointer',
textAlign: 'left',
fontSize: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.label || 'Dimension'}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>D{index + 1}</span>
</div>
</button>
))}
</div>
</>
)}
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
{artworks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
Import or drop artwork to create layers.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{[...artworks].reverse().map((item) => {
const displayIndex = artworks.findIndex(layer => layer.id === item.id) + 1;
return (
<button
key={item.id}
type="button"
draggable
onDragStart={() => { layerDragRef.current = item.id; }}
onDragOver={e => e.preventDefault()}
onDrop={() => {
reorderLayer(layerDragRef.current, item.id);
layerDragRef.current = null;
}}
onClick={() => {
setSelectedArtworkId(item.id);
setActiveDimensionId(null);
}}
style={{
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
color: 'var(--text-primary)',
borderRadius: 6,
padding: '8px 10px',
cursor: 'grab',
textAlign: 'left',
fontSize: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name || 'Artwork'}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>#{displayIndex}</span>
</div>
</button>
);
})}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5, marginTop: 12 }}>
Drag artwork layers up/down to change stacking. Use arrow keys to nudge selected artwork.
</div>
</div>
{/* Canvas area */}
<div
onDragOver={e => e.preventDefault()}
onDrop={handleArtworkDrop}
style={{ overflow: 'auto', background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
>
{!loaded && (
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>
)}
<canvas
ref={canvasRef}
style={{ display: loaded ? 'block' : 'none', cursor: calibrating || activeTool !== 'select' ? 'crosshair' : 'move', maxWidth: '100%', touchAction: 'none', userSelect: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
</div>
</div>
{/* Footer */}
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 8, flexShrink: 0 }}>
<button className="btn btn-outline btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-outline btn-sm" onClick={saveAsJpg}>Save as JPG</button>
<button className="btn btn-primary btn-sm" onClick={handleApply}>{effectiveApplyLabel}</button>
</div>
</div>
</div>
);
}