From 6b07fc98de162c86dc8566e209505dabed5e6966 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Wed, 18 Feb 2026 17:15:08 +0100 Subject: [PATCH] feat: Add logo comparison modal before overwriting existing logo When a company already has a logo, the fetch shows both old and new logos side-by-side so the user can choose to keep or replace. Uses preview file mechanism (slug_preview.webp) with confirm/cancel actions. Co-Authored-By: Claude Opus 4.6 --- blueprints/api/routes_company.py | 32 ++++- logo_fetch_service.py | 45 +++++- templates/company_detail.html | 240 +++++++++++++++++++++++++------ 3 files changed, 261 insertions(+), 56 deletions(-) diff --git a/blueprints/api/routes_company.py b/blueprints/api/routes_company.py index 933b92b..a1e8913 100644 --- a/blueprints/api/routes_company.py +++ b/blueprints/api/routes_company.py @@ -602,6 +602,11 @@ def api_fetch_company_logo(company_id): """ API: Fetch company logo from their website automatically. + Actions: + - fetch (default): Download logo, save as preview if logo exists + - confirm: Rename preview to final (after user comparison) + - cancel: Delete preview file + Accessible by users who can edit the company profile (MANAGER+) or admins. """ db = SessionLocal() @@ -610,27 +615,44 @@ def api_fetch_company_logo(company_id): if not company: return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404 - # Permission: can_edit_company (MANAGER+) or is_admin if not current_user.can_edit_company(company.id) and not current_user.is_admin: return jsonify({ 'success': False, 'error': 'Brak uprawnień do edycji profilu firmy' }), 403 + from logo_fetch_service import LogoFetchService + service = LogoFetchService() + + data = request.get_json(silent=True) or {} + action = data.get('action', 'fetch') + + if action == 'confirm': + file_ext = data.get('file_ext', 'webp') + ok = service.confirm_logo(company.slug, file_ext) + logger.info(f"Logo confirmed for {company.name} by {current_user.email}") + return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Brak pliku preview'}) + + if action == 'cancel': + service.cancel_logo(company.slug) + return jsonify({'success': True, 'message': 'Anulowano'}) + + # action == 'fetch' if not company.website: return jsonify({ 'success': False, 'error': 'Firma nie ma ustawionej strony WWW' }), 400 - from logo_fetch_service import LogoFetchService - service = LogoFetchService() - result = service.fetch_logo(company.website, company.slug) + has_logo = service.has_existing_logo(company.slug) + result = service.fetch_logo(company.website, company.slug, preview=bool(has_logo)) + result['has_existing_logo'] = has_logo is not None + result['existing_logo_ext'] = has_logo logger.info( f"Logo fetch for company {company.id} ({company.name}): " f"success={result['success']}, source={result.get('source')}, " - f"by={current_user.email}" + f"has_existing={has_logo is not None}, by={current_user.email}" ) return jsonify(result) diff --git a/logo_fetch_service.py b/logo_fetch_service.py index dc946b8..8f03211 100644 --- a/logo_fetch_service.py +++ b/logo_fetch_service.py @@ -39,11 +39,14 @@ LOGO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'i class LogoFetchService: - def fetch_logo(self, website_url: str, slug: str) -> dict: + def fetch_logo(self, website_url: str, slug: str, preview: bool = False) -> dict: """ Fetch logo from company website and save as WebP. - Returns: {'success': bool, 'message': str, 'source': str, 'steps': [...]} + Args: + preview: If True, save as {slug}_preview.{ext} instead of {slug}.{ext} + + Returns: {'success': bool, 'message': str, 'source': str, 'file_ext': str, 'steps': [...]} """ steps = [] candidates = [] @@ -98,17 +101,51 @@ class LogoFetchService: return {'success': False, 'message': 'Błąd konwersji obrazu', 'source': None, 'steps': steps} # Step 6: Save - saved_path = self._step_save(output_data, slug, file_ext, steps) + save_slug = f'{slug}_preview' if preview else slug + saved_path = self._step_save(output_data, save_slug, file_ext, steps) if saved_path is None: return {'success': False, 'message': 'Błąd zapisu pliku', 'source': None, 'steps': steps} return { 'success': True, - 'message': f'Logo pobrane z {image_source} i zapisane jako {slug}.{file_ext}', + 'message': f'Logo pobrane z {image_source} i zapisane jako {save_slug}.{file_ext}', 'source': image_source, + 'file_ext': file_ext, 'steps': steps } + @staticmethod + def confirm_logo(slug: str, file_ext: str) -> bool: + """Rename preview file to final.""" + preview = os.path.join(LOGO_DIR, f'{slug}_preview.{file_ext}') + final = os.path.join(LOGO_DIR, f'{slug}.{file_ext}') + if os.path.exists(preview): + # Remove old logo in other format if exists + for ext in ('webp', 'svg'): + old = os.path.join(LOGO_DIR, f'{slug}.{ext}') + if old != final and os.path.exists(old): + os.remove(old) + os.rename(preview, final) + return True + return False + + @staticmethod + def cancel_logo(slug: str) -> bool: + """Delete preview file.""" + for ext in ('webp', 'svg'): + preview = os.path.join(LOGO_DIR, f'{slug}_preview.{ext}') + if os.path.exists(preview): + os.remove(preview) + return True + + @staticmethod + def has_existing_logo(slug: str) -> str | None: + """Check if company already has a logo. Returns extension or None.""" + for ext in ('webp', 'svg'): + if os.path.exists(os.path.join(LOGO_DIR, f'{slug}.{ext}')): + return ext + return None + def _step_fetch_website(self, url, steps): """Step 1: Fetch the website HTML.""" try: diff --git a/templates/company_detail.html b/templates/company_detail.html index a2d51c3..ffa229d 100755 --- a/templates/company_detail.html +++ b/templates/company_detail.html @@ -362,6 +362,94 @@ .logo-step-icon.error svg { color: #ef4444; } .logo-step-icon.missing svg { color: #6b7280; } .logo-step-icon.skipped svg { color: #9ca3af; } + + /* Logo Comparison Modal */ + .logo-compare-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 2100; + align-items: center; + justify-content: center; + } + .logo-compare-overlay.active { display: flex; } + .logo-compare-content { + background: white; + border-radius: var(--radius-xl); + padding: var(--spacing-2xl); + max-width: 600px; + width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + } + .logo-compare-content h3 { + text-align: center; + margin: 0 0 var(--spacing-lg) 0; + font-size: var(--font-size-xl); + color: var(--text-primary); + } + .logo-compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + } + .logo-compare-item { + text-align: center; + } + .logo-compare-item .logo-compare-label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--spacing-sm); + } + .logo-compare-item .logo-compare-img { + width: 200px; + height: 200px; + margin: 0 auto; + background: var(--background); + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 2px solid var(--border); + } + .logo-compare-item .logo-compare-img img { + max-width: 90%; + max-height: 90%; + object-fit: contain; + } + .logo-compare-actions { + display: flex; + gap: var(--spacing-md); + justify-content: center; + } + .logo-compare-actions button { + padding: var(--spacing-sm) var(--spacing-xl); + border: none; + border-radius: var(--radius); + font-size: var(--font-size-sm); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + .logo-compare-actions .btn-keep { + background: var(--background); + color: var(--text-primary); + border: 1px solid var(--border); + } + .logo-compare-actions .btn-keep:hover { background: var(--border); } + .logo-compare-actions .btn-replace { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + } + .logo-compare-actions .btn-replace:hover { + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4); + } + .logo-step-text { font-size: var(--font-size-sm); color: var(--text-secondary); @@ -4024,6 +4112,31 @@ + +
+
+

Porównanie logo

+
+
+
Obecne logo
+
+ Obecne logo +
+
+
+
Nowe logo (ze strony WWW)
+
+ Nowe logo +
+
+
+
+ + +
+
+
+
@@ -4543,80 +4656,113 @@ function updateLogoStep(stepId, status, message) { const logoBtn = document.getElementById('logoFetchBtn'); if (!logoBtn) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + const companyId = logoBtn.dataset.companyId; + const slug = '{{ company.slug }}'; + + async function animateSteps(data) { + const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save']; + const stepsMap = {}; + if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; }); + + for (let i = 0; i < stepOrder.length; i++) { + const sid = stepOrder[i]; + const stepData = stepsMap[sid]; + if (stepData) { + updateLogoStep(sid, 'in_progress', stepData.message); + await sleep(300); + updateLogoStep(sid, stepData.status, stepData.message); + } else { + updateLogoStep(sid, 'skipped', null); + } + await sleep(300); + } + } + + async function sendLogoAction(action, extra) { + const body = { action, ...extra }; + const resp = await fetch(`/api/company/${companyId}/fetch-logo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, + body: JSON.stringify(body) + }); + return resp.json(); + } + logoBtn.addEventListener('click', async function() { - const companyId = this.dataset.companyId; const overlay = document.getElementById('logoLoadingOverlay'); - // Reset all steps to pending + // Reset steps ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'].forEach(s => { updateLogoStep(s, 'pending', null); const el = document.getElementById('logoStep_' + s); - if (el) { el.classList.remove('active', 'done'); } + if (el) el.classList.remove('active', 'done'); }); - // Show overlay, disable button overlay.classList.add('active'); this.classList.add('loading'); this.disabled = true; - - // Start spinner on first step updateLogoStep('fetch_website', 'in_progress', 'Sprawdzam stronę WWW firmy...'); try { - const response = await fetch(`/api/company/${companyId}/fetch-logo`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || '' - } - }); - - const data = await response.json(); - - // Animate steps with delay - const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save']; - const stepsMap = {}; - if (data.steps) { - data.steps.forEach(s => { stepsMap[s.step] = s; }); - } - - for (let i = 0; i < stepOrder.length; i++) { - const sid = stepOrder[i]; - const stepData = stepsMap[sid]; - if (stepData) { - updateLogoStep(sid, 'in_progress', stepData.message); - await sleep(300); - updateLogoStep(sid, stepData.status, stepData.message); - } else { - // Step not in response — mark as skipped - updateLogoStep(sid, 'skipped', null); - } - await sleep(300); - } - - // Pause so user can read results - await sleep(3000); - - // Hide overlay + const data = await sendLogoAction('fetch'); + await animateSteps(data); + await sleep(2000); overlay.classList.remove('active'); - if (data.success) { - showToast('Logo pobrane i zapisane!', 'success', 3000); - await sleep(2000); - window.location.reload(); - } else { + if (!data.success) { showToast(data.message || data.error || 'Nie udało się pobrać logo', 'error', 5000); this.classList.remove('loading'); this.disabled = false; + return; + } + + if (data.has_existing_logo) { + // Show comparison modal + const compareOverlay = document.getElementById('logoCompareOverlay'); + const oldImg = document.getElementById('logoCompareOld'); + const newImg = document.getElementById('logoCompareNew'); + const cacheBust = '?t=' + Date.now(); + oldImg.src = `/static/img/companies/${slug}.${data.existing_logo_ext}${cacheBust}`; + newImg.src = `/static/img/companies/${slug}_preview.${data.file_ext}${cacheBust}`; + compareOverlay.classList.add('active'); + + // Store file_ext for confirm action + compareOverlay.dataset.fileExt = data.file_ext; + } else { + // No existing logo — already saved directly + showToast('Logo pobrane i zapisane!', 'success', 3000); + await sleep(2000); + window.location.reload(); } } catch (error) { console.error('Logo fetch error:', error); - document.getElementById('logoLoadingOverlay').classList.remove('active'); + overlay.classList.remove('active'); showToast('Błąd połączenia z serwerem', 'error', 5000); this.classList.remove('loading'); this.disabled = false; } }); + + // Comparison modal: Keep old + document.getElementById('logoKeepOld').addEventListener('click', async function() { + await sendLogoAction('cancel'); + document.getElementById('logoCompareOverlay').classList.remove('active'); + showToast('Zachowano obecne logo', 'info', 3000); + logoBtn.classList.remove('loading'); + logoBtn.disabled = false; + }); + + // Comparison modal: Use new + document.getElementById('logoUseNew').addEventListener('click', async function() { + const compareOverlay = document.getElementById('logoCompareOverlay'); + const fileExt = compareOverlay.dataset.fileExt || 'webp'; + await sendLogoAction('confirm', { file_ext: fileExt }); + compareOverlay.classList.remove('active'); + showToast('Nowe logo zapisane!', 'success', 3000); + await sleep(2000); + window.location.reload(); + }); })();