feat: Replace single logo comparison with multi-candidate gallery
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Instead of auto-selecting one logo candidate, the service now downloads
up to 6 candidates and displays them in a gallery. User sees all options
(including their current logo if exists) and picks the best one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-18 17:21:35 +01:00
parent 6b07fc98de
commit 4119e44a58
3 changed files with 381 additions and 328 deletions

View File

@ -600,14 +600,12 @@ def api_enrich_company_registry(company_id):
@login_required
def api_fetch_company_logo(company_id):
"""
API: Fetch company logo from their website automatically.
API: Fetch company logo candidates from their website.
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.
- fetch (default): Download candidates, save as temp files
- confirm: Save chosen candidate as final logo (by index)
- cancel: Delete all candidate temp files
"""
db = SessionLocal()
try:
@ -628,13 +626,13 @@ def api_fetch_company_logo(company_id):
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'})
index = data.get('index', 0)
ok = service.confirm_candidate(company.slug, index)
logger.info(f"Logo candidate #{index} confirmed for {company.name} by {current_user.email}")
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Nie znaleziono kandydata'})
if action == 'cancel':
service.cancel_logo(company.slug)
service.cleanup_candidates(company.slug)
return jsonify({'success': True, 'message': 'Anulowano'})
# action == 'fetch'
@ -645,13 +643,13 @@ def api_fetch_company_logo(company_id):
}), 400
has_logo = service.has_existing_logo(company.slug)
result = service.fetch_logo(company.website, company.slug, preview=bool(has_logo))
result = service.fetch_candidates(company.website, company.slug)
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"success={result['success']}, candidates={len(result.get('candidates', []))}, "
f"has_existing={has_logo is not None}, by={current_user.email}"
)

View File

@ -1,21 +1,21 @@
"""
Logo Fetch Service - Automatically downloads company logos from their websites.
Logo Fetch Service - Downloads multiple logo candidates from company websites.
Strategies (in priority order):
1. og:image / twitter:image meta tags
1. <img> elements with "logo" in class/id/alt/src
2. apple-touch-icon / link rel="icon" (largest size)
3. <img> elements with "logo" in class/id/alt/src
3. og:image / twitter:image meta tags
4. Google Favicon API fallback
Steps reported to frontend:
- fetch_website: GET company website
- meta_tags: Parse og:image, twitter:image, favicon
- scan_images: Scan img elements for logo candidates
- download: Download best candidate image
- convert: Convert to WebP format
- save: Save to static/img/companies/{slug}.webp
Flow:
1. Fetch website, find all candidates
2. Download up to MAX_CANDIDATES, save as {slug}_cand_{i}.webp
3. Frontend shows gallery user picks one
4. confirm_candidate() renames chosen file to {slug}.webp
5. cleanup_candidates() removes temp files
"""
import glob
import logging
import os
import re
@ -30,35 +30,48 @@ logger = logging.getLogger(__name__)
USER_AGENT = 'Mozilla/5.0 (compatible; NordaBizBot/1.0)'
TIMEOUT = 10
MAX_DOWNLOAD_SIZE = 5 * 1024 * 1024 # 5MB
MIN_LOGO_SIZE = 64 # px
MIN_LOGO_SIZE = 32 # px (lowered to catch more candidates)
MAX_LOGO_SIZE = 800 # px
WEBP_QUALITY = 85
MAX_CANDIDATES = 6
LOGO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'img', 'companies')
SOURCE_LABELS = {
'img_scan': 'Element HTML',
'css_bg': 'Tło CSS',
'apple-touch-icon': 'Apple Touch Icon',
'og:image': 'Open Graph',
'twitter:image': 'Twitter Card',
'favicon': 'Favicon',
'google_favicon': 'Google Favicon',
}
class LogoFetchService:
def fetch_logo(self, website_url: str, slug: str, preview: bool = False) -> dict:
def fetch_candidates(self, website_url: str, slug: str) -> dict:
"""
Fetch logo from company website and save as WebP.
Fetch multiple logo candidates from company website.
Args:
preview: If True, save as {slug}_preview.{ext} instead of {slug}.{ext}
Returns: {'success': bool, 'message': str, 'source': str, 'file_ext': str, 'steps': [...]}
Returns: {
'success': bool,
'message': str,
'candidates': [{'index': 0, 'source': str, 'label': str, 'ext': str, 'width': int, 'height': int}, ...],
'steps': [...]
}
"""
steps = []
candidates = []
# Ensure URL has protocol
if not website_url.startswith('http'):
website_url = 'https://' + website_url
# Step 1: Fetch website
html, base_url = self._step_fetch_website(website_url, steps)
if html is None:
return {'success': False, 'message': steps[-1]['message'], 'source': None, 'steps': steps}
return {'success': False, 'message': steps[-1]['message'], 'candidates': [], 'steps': steps}
soup = BeautifulSoup(html, 'html.parser')
@ -79,64 +92,196 @@ class LogoFetchService:
if not candidates:
steps.append({'step': 'download', 'status': 'error', 'message': 'Nie znaleziono kandydatów na logo'})
steps.append({'step': 'convert', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
return {'success': False, 'message': 'Nie znaleziono logo na stronie firmy', 'source': None, 'steps': steps}
return {'success': False, 'message': 'Nie znaleziono logo na stronie firmy', 'candidates': [], 'steps': steps}
# Sort by priority (lower = better)
# Deduplicate by URL
seen_urls = set()
unique = []
for c in candidates:
if c['url'] not in seen_urls:
seen_urls.add(c['url'])
unique.append(c)
candidates = unique
# Sort by priority
candidates.sort(key=lambda c: c['priority'])
# Step 4: Download best candidate
image_data, image_source, content_type = self._step_download(candidates, steps)
if image_data is None:
steps.append({'step': 'convert', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
return {'success': False, 'message': 'Nie udało się pobrać żadnego kandydata', 'source': None, 'steps': steps}
# Step 4+5+6: Download, convert, save each candidate
saved = self._download_and_save_candidates(candidates, slug, steps)
# Step 5: Convert
is_svg = content_type and 'svg' in content_type
output_data, file_ext = self._step_convert(image_data, is_svg, steps)
if output_data is None:
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — błąd konwersji'})
return {'success': False, 'message': 'Błąd konwersji obrazu', 'source': None, 'steps': steps}
if not saved:
return {'success': False, 'message': 'Nie udało się pobrać żadnego kandydata', 'candidates': [], 'steps': steps}
# Step 6: Save
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}
steps.append({
'step': 'save',
'status': 'complete',
'message': f'Zapisano {len(saved)} kandydatów do wyboru'
})
return {
'success': True,
'message': f'Logo pobrane z {image_source} i zapisane jako {save_slug}.{file_ext}',
'source': image_source,
'file_ext': file_ext,
'message': f'Znaleziono {len(saved)} kandydatów na logo',
'candidates': saved,
'steps': steps
}
def _download_and_save_candidates(self, candidates, slug, steps):
"""Download up to MAX_CANDIDATES, convert and save each."""
saved = []
idx = 0
# Clean up old candidates first
self.cleanup_candidates(slug)
for candidate in candidates:
if idx >= MAX_CANDIDATES:
break
url = candidate['url']
try:
response = requests.get(url, timeout=TIMEOUT, headers={
'User-Agent': USER_AGENT
})
content_length = int(response.headers.get('content-length', 0))
if content_length > MAX_DOWNLOAD_SIZE:
continue
content_type = response.headers.get('content-type', '')
if not any(t in content_type for t in ['image', 'svg', 'octet-stream']):
if 'html' in content_type:
continue
data = response.content
if len(data) > MAX_DOWNLOAD_SIZE or len(data) < 100:
continue
is_svg = 'svg' in content_type
width, height = 0, 0
if not is_svg:
try:
from PIL import Image
img = Image.open(BytesIO(data))
width, height = img.size
if width < MIN_LOGO_SIZE or height < MIN_LOGO_SIZE:
continue
except Exception:
continue
# Convert
output_data, file_ext = self._convert(data, is_svg)
if output_data is None:
continue
# Get dimensions after conversion
if not is_svg and width == 0:
try:
from PIL import Image
img = Image.open(BytesIO(output_data))
width, height = img.size
except Exception:
pass
# Save as candidate file
os.makedirs(LOGO_DIR, exist_ok=True)
filename = f'{slug}_cand_{idx}.{file_ext}'
filepath = os.path.join(LOGO_DIR, filename)
with open(filepath, 'wb') as f:
f.write(output_data)
saved.append({
'index': idx,
'source': candidate['source'],
'label': SOURCE_LABELS.get(candidate['source'], candidate['source']),
'ext': file_ext,
'width': width,
'height': height,
'size': len(output_data),
'filename': filename
})
idx += 1
except Exception as e:
logger.debug(f"Failed to download candidate {url}: {e}")
continue
count = len(saved)
if count > 0:
steps.append({
'step': 'download',
'status': 'complete',
'message': f'Pobrano {count} obrazów'
})
else:
steps.append({
'step': 'download',
'status': 'error',
'message': 'Żaden kandydat nie spełnił wymagań'
})
return saved
def _convert(self, image_data, is_svg):
"""Convert image to WebP (or keep SVG). Returns (data, ext) or (None, None)."""
if is_svg:
return image_data, 'svg'
try:
from PIL import Image
img = Image.open(BytesIO(image_data))
if img.mode in ('RGBA', 'LA', 'P'):
if img.mode == 'P':
img = img.convert('RGBA')
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
background.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
img = background.convert('RGB')
elif img.mode != 'RGB':
img = img.convert('RGB')
w, h = img.size
if w > MAX_LOGO_SIZE or h > MAX_LOGO_SIZE:
img.thumbnail((MAX_LOGO_SIZE, MAX_LOGO_SIZE), Image.LANCZOS)
output = BytesIO()
img.save(output, format='WEBP', quality=WEBP_QUALITY)
return output.getvalue(), 'webp'
except Exception as e:
logger.debug(f"Conversion error: {e}")
return None, None
@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
def confirm_candidate(slug: str, index: int) -> bool:
"""Rename chosen candidate to final logo file."""
# Find candidate file
for ext in ('webp', 'svg'):
old = os.path.join(LOGO_DIR, f'{slug}.{ext}')
cand = os.path.join(LOGO_DIR, f'{slug}_cand_{index}.{ext}')
if os.path.exists(cand):
final = os.path.join(LOGO_DIR, f'{slug}.{ext}')
# Remove old logo in other format
for old_ext in ('webp', 'svg'):
old = os.path.join(LOGO_DIR, f'{slug}.{old_ext}')
if old != final and os.path.exists(old):
os.remove(old)
os.rename(preview, final)
os.rename(cand, final)
# Cleanup remaining candidates
LogoFetchService.cleanup_candidates(slug)
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
def cleanup_candidates(slug: str):
"""Delete all candidate files for a slug."""
pattern = os.path.join(LOGO_DIR, f'{slug}_cand_*')
for f in glob.glob(pattern):
try:
os.remove(f)
except OSError:
pass
@staticmethod
def has_existing_logo(slug: str) -> str | None:
@ -161,7 +306,6 @@ class LogoFetchService:
})
return response.text, response.url
except requests.exceptions.SSLError:
# Retry without SSL verification
try:
http_url = url.replace('https://', 'http://')
response = requests.get(http_url, timeout=TIMEOUT, headers={
@ -193,21 +337,18 @@ class LogoFetchService:
"""Step 2: Search meta tags for logo candidates."""
found = []
# og:image
og_img = soup.find('meta', property='og:image')
if og_img and og_img.get('content'):
url = urljoin(base_url, og_img['content'])
candidates.append({'url': url, 'source': 'og:image', 'priority': 10})
found.append('og:image')
# twitter:image
tw_img = soup.find('meta', attrs={'name': 'twitter:image'})
if tw_img and tw_img.get('content'):
url = urljoin(base_url, tw_img['content'])
candidates.append({'url': url, 'source': 'twitter:image', 'priority': 11})
found.append('twitter:image')
# apple-touch-icon (prefer largest)
touch_icons = soup.find_all('link', rel=lambda r: r and 'apple-touch-icon' in r)
if touch_icons:
best = max(touch_icons, key=lambda t: self._parse_size(t.get('sizes', '0x0')))
@ -216,28 +357,19 @@ class LogoFetchService:
candidates.append({'url': url, 'source': 'apple-touch-icon', 'priority': 5})
found.append('apple-touch-icon')
# link rel="icon" (prefer largest, skip tiny favicons)
icons = soup.find_all('link', rel=lambda r: r and 'icon' in r and 'apple' not in str(r))
for icon in icons:
size = self._parse_size(icon.get('sizes', '0x0'))
href = icon.get('href', '')
if href and size >= 64:
if href and size >= 32:
url = urljoin(base_url, href)
candidates.append({'url': url, 'source': 'favicon', 'priority': 15})
found.append(f'favicon ({icon.get("sizes", "?")})')
if found:
steps.append({
'step': 'meta_tags',
'status': 'complete',
'message': f'Znaleziono: {", ".join(found)}'
})
steps.append({'step': 'meta_tags', 'status': 'complete', 'message': f'Znaleziono: {", ".join(found)}'})
else:
steps.append({
'step': 'meta_tags',
'status': 'missing',
'message': 'Brak meta tagów z logo'
})
steps.append({'step': 'meta_tags', 'status': 'missing', 'message': 'Brak meta tagów z logo'})
def _step_scan_images(self, soup, base_url, candidates, steps):
"""Step 3: Scan img elements for logo candidates."""
@ -255,16 +387,14 @@ class LogoFetchService:
src = img.get('src') or img.get('data-src') or img.get('data-lazy-src')
if src:
url = urljoin(base_url, src)
# Prioritize based on attribute match
priority = 20
if 'logo' in (img.get('id', '') + ' '.join(img.get('class', []))).lower():
priority = 3 # Class/ID match is very strong signal
priority = 3
elif 'logo' in img.get('alt', '').lower():
priority = 8
candidates.append({'url': url, 'source': 'img_scan', 'priority': priority})
found_count += 1
# Also check CSS background images in header/nav
for el in soup.select('header a[class*="logo"], nav a[class*="logo"], .logo, #logo, [class*="brand"]'):
style = el.get('style', '')
bg_match = re.search(r'url\(["\']?([^"\')\s]+)["\']?\)', style)
@ -274,150 +404,9 @@ class LogoFetchService:
found_count += 1
if found_count > 0:
steps.append({
'step': 'scan_images',
'status': 'complete',
'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'
})
steps.append({'step': 'scan_images', 'status': 'complete', 'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'})
else:
steps.append({
'step': 'scan_images',
'status': 'missing',
'message': 'Brak elementów img z "logo" w atrybutach'
})
def _step_download(self, candidates, steps):
"""Step 4: Download the best candidate image."""
for candidate in candidates:
url = candidate['url']
try:
response = requests.get(url, timeout=TIMEOUT, headers={
'User-Agent': USER_AGENT
}, stream=True)
content_length = int(response.headers.get('content-length', 0))
if content_length > MAX_DOWNLOAD_SIZE:
logger.debug(f"Skipping {url}: too large ({content_length} bytes)")
continue
content_type = response.headers.get('content-type', '')
# Verify it's an image
if not any(t in content_type for t in ['image', 'svg', 'octet-stream']):
# Could be a redirect to HTML page (common for og:image on some sites)
if 'html' in content_type:
continue
data = response.content
if len(data) > MAX_DOWNLOAD_SIZE:
continue
# For raster images, verify dimensions
if 'svg' not in content_type:
try:
from PIL import Image
img = Image.open(BytesIO(data))
w, h = img.size
if w < MIN_LOGO_SIZE or h < MIN_LOGO_SIZE:
logger.debug(f"Skipping {url}: too small ({w}x{h})")
continue
except Exception:
continue
steps.append({
'step': 'download',
'status': 'complete',
'message': f'Pobrano obraz z {candidate["source"]} ({len(data)} bajtów)'
})
return data, candidate['source'], content_type
except Exception as e:
logger.debug(f"Failed to download {url}: {e}")
continue
steps.append({
'step': 'download',
'status': 'error',
'message': 'Żaden kandydat nie spełnił wymagań (rozmiar, format)'
})
return None, None, None
def _step_convert(self, image_data, is_svg, steps):
"""Step 5: Convert image to WebP (or keep SVG)."""
if is_svg:
steps.append({
'step': 'convert',
'status': 'complete',
'message': 'Format SVG — zapisuję bez konwersji'
})
return image_data, 'svg'
try:
from PIL import Image
img = Image.open(BytesIO(image_data))
# Convert RGBA/P to RGB for WebP
if img.mode in ('RGBA', 'LA', 'P'):
if img.mode == 'P':
img = img.convert('RGBA')
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
background.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
img = background.convert('RGB')
elif img.mode != 'RGB':
img = img.convert('RGB')
# Resize if too large
w, h = img.size
if w > MAX_LOGO_SIZE or h > MAX_LOGO_SIZE:
img.thumbnail((MAX_LOGO_SIZE, MAX_LOGO_SIZE), Image.LANCZOS)
w, h = img.size
# Save to WebP
output = BytesIO()
img.save(output, format='WEBP', quality=WEBP_QUALITY)
output_data = output.getvalue()
steps.append({
'step': 'convert',
'status': 'complete',
'message': f'Konwersja do WebP ({w}x{h}, {len(output_data)} bajtów)'
})
return output_data, 'webp'
except Exception as e:
steps.append({
'step': 'convert',
'status': 'error',
'message': f'Błąd konwersji: {str(e)[:100]}'
})
return None, None
def _step_save(self, data, slug, ext, steps):
"""Step 6: Save the file to disk."""
try:
os.makedirs(LOGO_DIR, exist_ok=True)
filename = f'{slug}.{ext}'
filepath = os.path.join(LOGO_DIR, filename)
with open(filepath, 'wb') as f:
f.write(data)
steps.append({
'step': 'save',
'status': 'complete',
'message': f'Zapisano jako {filename}'
})
return filepath
except Exception as e:
steps.append({
'step': 'save',
'status': 'error',
'message': f'Błąd zapisu: {str(e)[:100]}'
})
return None
steps.append({'step': 'scan_images', 'status': 'missing', 'message': 'Brak elementów img z "logo" w atrybutach'})
@staticmethod
def _parse_size(sizes_str):

View File

@ -363,8 +363,8 @@
.logo-step-icon.missing svg { color: #6b7280; }
.logo-step-icon.skipped svg { color: #9ca3af; }
/* Logo Comparison Modal */
.logo-compare-overlay {
/* Logo Gallery Modal */
.logo-gallery-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@ -373,61 +373,89 @@
align-items: center;
justify-content: center;
}
.logo-compare-overlay.active { display: flex; }
.logo-compare-content {
.logo-gallery-overlay.active { display: flex; }
.logo-gallery-content {
background: white;
border-radius: var(--radius-xl);
padding: var(--spacing-2xl);
max-width: 600px;
max-width: 700px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.logo-compare-content h3 {
.logo-gallery-content h3 {
text-align: center;
margin: 0 0 var(--spacing-lg) 0;
margin: 0 0 4px 0;
font-size: var(--font-size-xl);
color: var(--text-primary);
}
.logo-compare-grid {
.logo-gallery-content .gallery-subtitle {
text-align: center;
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
}
.logo-gallery-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.logo-compare-item {
.logo-gallery-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);
cursor: pointer;
padding: var(--spacing-sm);
border-radius: var(--radius-lg);
border: 3px solid transparent;
transition: all 0.2s ease;
}
.logo-gallery-item:hover { border-color: #e5e7eb; background: #fafafa; }
.logo-gallery-item.selected { border-color: #f59e0b; background: #fffbeb; }
.logo-gallery-item.current { border-color: #10b981; background: #ecfdf5; }
.logo-gallery-item .gallery-img {
width: 120px;
height: 120px;
margin: 0 auto var(--spacing-xs);
background: white;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid var(--border);
border: 1px solid #e5e7eb;
}
.logo-compare-item .logo-compare-img img {
.logo-gallery-item .gallery-img img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.logo-compare-actions {
.logo-gallery-item .gallery-label {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
.logo-gallery-item .gallery-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
margin-top: 2px;
}
.logo-gallery-item.current .gallery-badge {
background: #d1fae5;
color: #065f46;
}
.logo-gallery-item .gallery-size {
font-size: 10px;
color: #9ca3af;
}
.logo-gallery-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
.logo-compare-actions button {
.logo-gallery-actions button {
padding: var(--spacing-sm) var(--spacing-xl);
border: none;
border-radius: var(--radius);
@ -436,19 +464,24 @@
cursor: pointer;
transition: all 0.2s ease;
}
.logo-compare-actions .btn-keep {
.logo-gallery-actions .btn-cancel-gallery {
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 {
.logo-gallery-actions .btn-cancel-gallery:hover { background: var(--border); }
.logo-gallery-actions .btn-confirm-gallery {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.logo-compare-actions .btn-replace:hover {
.logo-gallery-actions .btn-confirm-gallery:hover {
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
.logo-gallery-actions .btn-confirm-gallery:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.logo-step-text {
font-size: var(--font-size-sm);
@ -4112,27 +4145,17 @@
</div>
</div>
<!-- Logo Comparison Modal -->
<div class="logo-compare-overlay" id="logoCompareOverlay">
<div class="logo-compare-content">
<h3>Porównanie logo</h3>
<div class="logo-compare-grid">
<div class="logo-compare-item">
<div class="logo-compare-label">Obecne logo</div>
<div class="logo-compare-img">
<img id="logoCompareOld" src="" alt="Obecne logo">
<!-- Logo Gallery Modal -->
<div class="logo-gallery-overlay" id="logoGalleryOverlay">
<div class="logo-gallery-content">
<h3>Wybierz logo</h3>
<p class="gallery-subtitle">Kliknij na logo, które chcesz użyć dla firmy</p>
<div class="logo-gallery-grid" id="logoGalleryGrid">
<!-- Dynamically populated by JS -->
</div>
</div>
<div class="logo-compare-item">
<div class="logo-compare-label">Nowe logo (ze strony WWW)</div>
<div class="logo-compare-img">
<img id="logoCompareNew" src="" alt="Nowe logo">
</div>
</div>
</div>
<div class="logo-compare-actions">
<button class="btn-keep" id="logoKeepOld">Zachowaj obecne</button>
<button class="btn-replace" id="logoUseNew">Użyj nowego</button>
<div class="logo-gallery-actions">
<button class="btn-cancel-gallery" id="logoGalleryCancel">Anuluj</button>
<button class="btn-confirm-gallery" id="logoGalleryConfirm" disabled>Użyj wybranego</button>
</div>
</div>
</div>
@ -4167,13 +4190,7 @@
<div class="logo-step-icon pending" id="logoStepIcon_download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>
</div>
<span class="logo-step-text" id="logoStepText_download">Pobieram najlepszy kandydat...</span>
</div>
<div class="logo-step" id="logoStep_convert">
<div class="logo-step-icon pending" id="logoStepIcon_convert">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>
</div>
<span class="logo-step-text" id="logoStepText_convert">Konwertuję do formatu WebP...</span>
<span class="logo-step-text" id="logoStepText_download">Pobieram i konwertuję obrazy...</span>
</div>
<div class="logo-step" id="logoStep_save">
<div class="logo-step-icon pending" id="logoStepIcon_save">
@ -4659,9 +4676,10 @@ function updateLogoStep(stepId, status, message) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const companyId = logoBtn.dataset.companyId;
const slug = '{{ company.slug }}';
let selectedIndex = null;
async function animateSteps(data) {
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'];
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'save'];
const stepsMap = {};
if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; });
@ -4689,11 +4707,62 @@ function updateLogoStep(stepId, status, message) {
return resp.json();
}
function buildGallery(candidates, existingLogoExt) {
const grid = document.getElementById('logoGalleryGrid');
grid.innerHTML = '';
selectedIndex = null;
const cacheBust = '?t=' + Date.now();
// Show existing logo first if exists
if (existingLogoExt) {
const item = document.createElement('div');
item.className = 'logo-gallery-item current';
item.dataset.index = 'current';
item.innerHTML = `
<div class="gallery-img">
<img src="/static/img/companies/${slug}.${existingLogoExt}${cacheBust}" alt="Obecne logo">
</div>
<div class="gallery-label">Obecne logo</div>
<span class="gallery-badge">obecne</span>
`;
grid.appendChild(item);
}
// Show candidates
candidates.forEach(c => {
const item = document.createElement('div');
item.className = 'logo-gallery-item';
item.dataset.index = c.index;
const dims = c.width ? `${c.width}x${c.height}` : 'SVG';
item.innerHTML = `
<div class="gallery-img">
<img src="/static/img/companies/${c.filename}${cacheBust}" alt="${c.label}">
</div>
<div class="gallery-label">${c.label}</div>
<div class="gallery-size">${dims}</div>
`;
item.addEventListener('click', () => selectCandidate(item, c.index));
grid.appendChild(item);
});
document.getElementById('logoGalleryConfirm').disabled = true;
}
function selectCandidate(item, index) {
// Deselect all
document.querySelectorAll('.logo-gallery-item').forEach(el => el.classList.remove('selected'));
// Select clicked (unless it's the current logo)
if (item.dataset.index === 'current') return;
item.classList.add('selected');
selectedIndex = index;
document.getElementById('logoGalleryConfirm').disabled = false;
}
logoBtn.addEventListener('click', async function() {
const overlay = document.getElementById('logoLoadingOverlay');
// Reset steps
['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'].forEach(s => {
['fetch_website', 'meta_tags', 'scan_images', 'download', 'save'].forEach(s => {
updateLogoStep(s, 'pending', null);
const el = document.getElementById('logoStep_' + s);
if (el) el.classList.remove('active', 'done');
@ -4707,7 +4776,7 @@ function updateLogoStep(stepId, status, message) {
try {
const data = await sendLogoAction('fetch');
await animateSteps(data);
await sleep(2000);
await sleep(1500);
overlay.classList.remove('active');
if (!data.success) {
@ -4717,24 +4786,10 @@ function updateLogoStep(stepId, status, message) {
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');
// Show gallery with all candidates
buildGallery(data.candidates, data.existing_logo_ext);
document.getElementById('logoGalleryOverlay').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);
overlay.classList.remove('active');
@ -4744,25 +4799,36 @@ function updateLogoStep(stepId, status, message) {
}
});
// Comparison modal: Keep old
document.getElementById('logoKeepOld').addEventListener('click', async function() {
// Gallery: Cancel
document.getElementById('logoGalleryCancel').addEventListener('click', async function() {
await sendLogoAction('cancel');
document.getElementById('logoCompareOverlay').classList.remove('active');
showToast('Zachowano obecne logo', 'info', 3000);
document.getElementById('logoGalleryOverlay').classList.remove('active');
showToast('Anulowano', 'info', 2000);
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);
// Gallery: Confirm
document.getElementById('logoGalleryConfirm').addEventListener('click', async function() {
if (selectedIndex === null) return;
this.disabled = true;
this.textContent = 'Zapisuję...';
await sendLogoAction('confirm', { index: selectedIndex });
document.getElementById('logoGalleryOverlay').classList.remove('active');
showToast('Logo zapisane!', 'success', 3000);
await sleep(2000);
window.location.reload();
});
// Close gallery on backdrop click
document.getElementById('logoGalleryOverlay').addEventListener('click', function(e) {
if (e.target === this) {
sendLogoAction('cancel');
this.classList.remove('active');
logoBtn.classList.remove('loading');
logoBtn.disabled = false;
}
});
})();
</script>