3721 lines
152 KiB
React
3721 lines
152 KiB
React
import { useState, useEffect, useRef } from 'react';
|
||
import Layout from '../../components/Layout';
|
||
import LoadingButton from '../../components/LoadingButton';
|
||
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('');
|
||
|
||
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 className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||
<div className="request-toolbar-section">
|
||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||
<div className="request-filter-row">
|
||
<button
|
||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterCompany('')}
|
||
>
|
||
All
|
||
</button>
|
||
{companyNames.map(name => (
|
||
<button
|
||
key={name}
|
||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
||
>
|
||
{name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<th>Name</th>
|
||
<th>Revision</th>
|
||
<th>Sign Count</th>
|
||
<th>Client</th>
|
||
<th>Updated</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredBooks.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 btn-outline btn-sm"
|
||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||
onClick={() => handleDelete(book)}
|
||
>
|
||
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: 6, 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: 6, 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>
|
||
);
|
||
}
|