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
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:
parent
6b07fc98de
commit
4119e44a58
@ -600,14 +600,12 @@ def api_enrich_company_registry(company_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def api_fetch_company_logo(company_id):
|
def api_fetch_company_logo(company_id):
|
||||||
"""
|
"""
|
||||||
API: Fetch company logo from their website automatically.
|
API: Fetch company logo candidates from their website.
|
||||||
|
|
||||||
Actions:
|
Actions:
|
||||||
- fetch (default): Download logo, save as preview if logo exists
|
- fetch (default): Download candidates, save as temp files
|
||||||
- confirm: Rename preview to final (after user comparison)
|
- confirm: Save chosen candidate as final logo (by index)
|
||||||
- cancel: Delete preview file
|
- cancel: Delete all candidate temp files
|
||||||
|
|
||||||
Accessible by users who can edit the company profile (MANAGER+) or admins.
|
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@ -628,13 +626,13 @@ def api_fetch_company_logo(company_id):
|
|||||||
action = data.get('action', 'fetch')
|
action = data.get('action', 'fetch')
|
||||||
|
|
||||||
if action == 'confirm':
|
if action == 'confirm':
|
||||||
file_ext = data.get('file_ext', 'webp')
|
index = data.get('index', 0)
|
||||||
ok = service.confirm_logo(company.slug, file_ext)
|
ok = service.confirm_candidate(company.slug, index)
|
||||||
logger.info(f"Logo confirmed for {company.name} by {current_user.email}")
|
logger.info(f"Logo candidate #{index} confirmed for {company.name} by {current_user.email}")
|
||||||
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Brak pliku preview'})
|
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Nie znaleziono kandydata'})
|
||||||
|
|
||||||
if action == 'cancel':
|
if action == 'cancel':
|
||||||
service.cancel_logo(company.slug)
|
service.cleanup_candidates(company.slug)
|
||||||
return jsonify({'success': True, 'message': 'Anulowano'})
|
return jsonify({'success': True, 'message': 'Anulowano'})
|
||||||
|
|
||||||
# action == 'fetch'
|
# action == 'fetch'
|
||||||
@ -645,13 +643,13 @@ def api_fetch_company_logo(company_id):
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
has_logo = service.has_existing_logo(company.slug)
|
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['has_existing_logo'] = has_logo is not None
|
||||||
result['existing_logo_ext'] = has_logo
|
result['existing_logo_ext'] = has_logo
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Logo fetch for company {company.id} ({company.name}): "
|
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}"
|
f"has_existing={has_logo is not None}, by={current_user.email}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
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)
|
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
|
4. Google Favicon API fallback
|
||||||
|
|
||||||
Steps reported to frontend:
|
Flow:
|
||||||
- fetch_website: GET company website
|
1. Fetch website, find all candidates
|
||||||
- meta_tags: Parse og:image, twitter:image, favicon
|
2. Download up to MAX_CANDIDATES, save as {slug}_cand_{i}.webp
|
||||||
- scan_images: Scan img elements for logo candidates
|
3. Frontend shows gallery — user picks one
|
||||||
- download: Download best candidate image
|
4. confirm_candidate() renames chosen file to {slug}.webp
|
||||||
- convert: Convert to WebP format
|
5. cleanup_candidates() removes temp files
|
||||||
- save: Save to static/img/companies/{slug}.webp
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -30,35 +30,48 @@ logger = logging.getLogger(__name__)
|
|||||||
USER_AGENT = 'Mozilla/5.0 (compatible; NordaBizBot/1.0)'
|
USER_AGENT = 'Mozilla/5.0 (compatible; NordaBizBot/1.0)'
|
||||||
TIMEOUT = 10
|
TIMEOUT = 10
|
||||||
MAX_DOWNLOAD_SIZE = 5 * 1024 * 1024 # 5MB
|
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
|
MAX_LOGO_SIZE = 800 # px
|
||||||
WEBP_QUALITY = 85
|
WEBP_QUALITY = 85
|
||||||
|
MAX_CANDIDATES = 6
|
||||||
|
|
||||||
LOGO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'img', 'companies')
|
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:
|
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:
|
Returns: {
|
||||||
preview: If True, save as {slug}_preview.{ext} instead of {slug}.{ext}
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
Returns: {'success': bool, 'message': str, 'source': str, 'file_ext': str, 'steps': [...]}
|
'candidates': [{'index': 0, 'source': str, 'label': str, 'ext': str, 'width': int, 'height': int}, ...],
|
||||||
|
'steps': [...]
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
steps = []
|
steps = []
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
||||||
# Ensure URL has protocol
|
|
||||||
if not website_url.startswith('http'):
|
if not website_url.startswith('http'):
|
||||||
website_url = 'https://' + website_url
|
website_url = 'https://' + website_url
|
||||||
|
|
||||||
# Step 1: Fetch website
|
# Step 1: Fetch website
|
||||||
html, base_url = self._step_fetch_website(website_url, steps)
|
html, base_url = self._step_fetch_website(website_url, steps)
|
||||||
if html is None:
|
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')
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
|
||||||
@ -79,64 +92,196 @@ class LogoFetchService:
|
|||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
steps.append({'step': 'download', 'status': 'error', 'message': 'Nie znaleziono kandydatów na logo'})
|
steps.append({'step': 'download', 'status': 'error', 'message': 'Nie znaleziono kandydatów na logo'})
|
||||||
steps.append({'step': 'convert', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
|
return {'success': False, 'message': 'Nie znaleziono logo na stronie firmy', 'candidates': [], 'steps': steps}
|
||||||
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}
|
|
||||||
|
|
||||||
# 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'])
|
candidates.sort(key=lambda c: c['priority'])
|
||||||
|
|
||||||
# Step 4: Download best candidate
|
# Step 4+5+6: Download, convert, save each candidate
|
||||||
image_data, image_source, content_type = self._step_download(candidates, steps)
|
saved = self._download_and_save_candidates(candidates, slug, 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 5: Convert
|
if not saved:
|
||||||
is_svg = content_type and 'svg' in content_type
|
return {'success': False, 'message': 'Nie udało się pobrać żadnego kandydata', 'candidates': [], 'steps': steps}
|
||||||
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}
|
|
||||||
|
|
||||||
# Step 6: Save
|
steps.append({
|
||||||
save_slug = f'{slug}_preview' if preview else slug
|
'step': 'save',
|
||||||
saved_path = self._step_save(output_data, save_slug, file_ext, steps)
|
'status': 'complete',
|
||||||
if saved_path is None:
|
'message': f'Zapisano {len(saved)} kandydatów do wyboru'
|
||||||
return {'success': False, 'message': 'Błąd zapisu pliku', 'source': None, 'steps': steps}
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': f'Logo pobrane z {image_source} i zapisane jako {save_slug}.{file_ext}',
|
'message': f'Znaleziono {len(saved)} kandydatów na logo',
|
||||||
'source': image_source,
|
'candidates': saved,
|
||||||
'file_ext': file_ext,
|
|
||||||
'steps': steps
|
'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
|
@staticmethod
|
||||||
def confirm_logo(slug: str, file_ext: str) -> bool:
|
def confirm_candidate(slug: str, index: int) -> bool:
|
||||||
"""Rename preview file to final."""
|
"""Rename chosen candidate to final logo file."""
|
||||||
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{file_ext}')
|
# Find candidate file
|
||||||
final = os.path.join(LOGO_DIR, f'{slug}.{file_ext}')
|
for ext in ('webp', 'svg'):
|
||||||
if os.path.exists(preview):
|
cand = os.path.join(LOGO_DIR, f'{slug}_cand_{index}.{ext}')
|
||||||
# Remove old logo in other format if exists
|
if os.path.exists(cand):
|
||||||
for ext in ('webp', 'svg'):
|
final = os.path.join(LOGO_DIR, f'{slug}.{ext}')
|
||||||
old = os.path.join(LOGO_DIR, f'{slug}.{ext}')
|
# Remove old logo in other format
|
||||||
if old != final and os.path.exists(old):
|
for old_ext in ('webp', 'svg'):
|
||||||
os.remove(old)
|
old = os.path.join(LOGO_DIR, f'{slug}.{old_ext}')
|
||||||
os.rename(preview, final)
|
if old != final and os.path.exists(old):
|
||||||
return True
|
os.remove(old)
|
||||||
|
os.rename(cand, final)
|
||||||
|
# Cleanup remaining candidates
|
||||||
|
LogoFetchService.cleanup_candidates(slug)
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cancel_logo(slug: str) -> bool:
|
def cleanup_candidates(slug: str):
|
||||||
"""Delete preview file."""
|
"""Delete all candidate files for a slug."""
|
||||||
for ext in ('webp', 'svg'):
|
pattern = os.path.join(LOGO_DIR, f'{slug}_cand_*')
|
||||||
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{ext}')
|
for f in glob.glob(pattern):
|
||||||
if os.path.exists(preview):
|
try:
|
||||||
os.remove(preview)
|
os.remove(f)
|
||||||
return True
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_existing_logo(slug: str) -> str | None:
|
def has_existing_logo(slug: str) -> str | None:
|
||||||
@ -161,7 +306,6 @@ class LogoFetchService:
|
|||||||
})
|
})
|
||||||
return response.text, response.url
|
return response.text, response.url
|
||||||
except requests.exceptions.SSLError:
|
except requests.exceptions.SSLError:
|
||||||
# Retry without SSL verification
|
|
||||||
try:
|
try:
|
||||||
http_url = url.replace('https://', 'http://')
|
http_url = url.replace('https://', 'http://')
|
||||||
response = requests.get(http_url, timeout=TIMEOUT, headers={
|
response = requests.get(http_url, timeout=TIMEOUT, headers={
|
||||||
@ -193,21 +337,18 @@ class LogoFetchService:
|
|||||||
"""Step 2: Search meta tags for logo candidates."""
|
"""Step 2: Search meta tags for logo candidates."""
|
||||||
found = []
|
found = []
|
||||||
|
|
||||||
# og:image
|
|
||||||
og_img = soup.find('meta', property='og:image')
|
og_img = soup.find('meta', property='og:image')
|
||||||
if og_img and og_img.get('content'):
|
if og_img and og_img.get('content'):
|
||||||
url = urljoin(base_url, og_img['content'])
|
url = urljoin(base_url, og_img['content'])
|
||||||
candidates.append({'url': url, 'source': 'og:image', 'priority': 10})
|
candidates.append({'url': url, 'source': 'og:image', 'priority': 10})
|
||||||
found.append('og:image')
|
found.append('og:image')
|
||||||
|
|
||||||
# twitter:image
|
|
||||||
tw_img = soup.find('meta', attrs={'name': 'twitter:image'})
|
tw_img = soup.find('meta', attrs={'name': 'twitter:image'})
|
||||||
if tw_img and tw_img.get('content'):
|
if tw_img and tw_img.get('content'):
|
||||||
url = urljoin(base_url, tw_img['content'])
|
url = urljoin(base_url, tw_img['content'])
|
||||||
candidates.append({'url': url, 'source': 'twitter:image', 'priority': 11})
|
candidates.append({'url': url, 'source': 'twitter:image', 'priority': 11})
|
||||||
found.append('twitter:image')
|
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)
|
touch_icons = soup.find_all('link', rel=lambda r: r and 'apple-touch-icon' in r)
|
||||||
if touch_icons:
|
if touch_icons:
|
||||||
best = max(touch_icons, key=lambda t: self._parse_size(t.get('sizes', '0x0')))
|
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})
|
candidates.append({'url': url, 'source': 'apple-touch-icon', 'priority': 5})
|
||||||
found.append('apple-touch-icon')
|
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))
|
icons = soup.find_all('link', rel=lambda r: r and 'icon' in r and 'apple' not in str(r))
|
||||||
for icon in icons:
|
for icon in icons:
|
||||||
size = self._parse_size(icon.get('sizes', '0x0'))
|
size = self._parse_size(icon.get('sizes', '0x0'))
|
||||||
href = icon.get('href', '')
|
href = icon.get('href', '')
|
||||||
if href and size >= 64:
|
if href and size >= 32:
|
||||||
url = urljoin(base_url, href)
|
url = urljoin(base_url, href)
|
||||||
candidates.append({'url': url, 'source': 'favicon', 'priority': 15})
|
candidates.append({'url': url, 'source': 'favicon', 'priority': 15})
|
||||||
found.append(f'favicon ({icon.get("sizes", "?")})')
|
found.append(f'favicon ({icon.get("sizes", "?")})')
|
||||||
|
|
||||||
if found:
|
if found:
|
||||||
steps.append({
|
steps.append({'step': 'meta_tags', 'status': 'complete', 'message': f'Znaleziono: {", ".join(found)}'})
|
||||||
'step': 'meta_tags',
|
|
||||||
'status': 'complete',
|
|
||||||
'message': f'Znaleziono: {", ".join(found)}'
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
steps.append({
|
steps.append({'step': 'meta_tags', 'status': 'missing', 'message': 'Brak meta tagów z logo'})
|
||||||
'step': 'meta_tags',
|
|
||||||
'status': 'missing',
|
|
||||||
'message': 'Brak meta tagów z logo'
|
|
||||||
})
|
|
||||||
|
|
||||||
def _step_scan_images(self, soup, base_url, candidates, steps):
|
def _step_scan_images(self, soup, base_url, candidates, steps):
|
||||||
"""Step 3: Scan img elements for logo candidates."""
|
"""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')
|
src = img.get('src') or img.get('data-src') or img.get('data-lazy-src')
|
||||||
if src:
|
if src:
|
||||||
url = urljoin(base_url, src)
|
url = urljoin(base_url, src)
|
||||||
# Prioritize based on attribute match
|
|
||||||
priority = 20
|
priority = 20
|
||||||
if 'logo' in (img.get('id', '') + ' '.join(img.get('class', []))).lower():
|
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():
|
elif 'logo' in img.get('alt', '').lower():
|
||||||
priority = 8
|
priority = 8
|
||||||
candidates.append({'url': url, 'source': 'img_scan', 'priority': priority})
|
candidates.append({'url': url, 'source': 'img_scan', 'priority': priority})
|
||||||
found_count += 1
|
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"]'):
|
for el in soup.select('header a[class*="logo"], nav a[class*="logo"], .logo, #logo, [class*="brand"]'):
|
||||||
style = el.get('style', '')
|
style = el.get('style', '')
|
||||||
bg_match = re.search(r'url\(["\']?([^"\')\s]+)["\']?\)', style)
|
bg_match = re.search(r'url\(["\']?([^"\')\s]+)["\']?\)', style)
|
||||||
@ -274,150 +404,9 @@ class LogoFetchService:
|
|||||||
found_count += 1
|
found_count += 1
|
||||||
|
|
||||||
if found_count > 0:
|
if found_count > 0:
|
||||||
steps.append({
|
steps.append({'step': 'scan_images', 'status': 'complete', 'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'})
|
||||||
'step': 'scan_images',
|
|
||||||
'status': 'complete',
|
|
||||||
'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
steps.append({
|
steps.append({'step': 'scan_images', 'status': 'missing', 'message': 'Brak elementów img z "logo" w atrybutach'})
|
||||||
'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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_size(sizes_str):
|
def _parse_size(sizes_str):
|
||||||
|
|||||||
@ -363,8 +363,8 @@
|
|||||||
.logo-step-icon.missing svg { color: #6b7280; }
|
.logo-step-icon.missing svg { color: #6b7280; }
|
||||||
.logo-step-icon.skipped svg { color: #9ca3af; }
|
.logo-step-icon.skipped svg { color: #9ca3af; }
|
||||||
|
|
||||||
/* Logo Comparison Modal */
|
/* Logo Gallery Modal */
|
||||||
.logo-compare-overlay {
|
.logo-gallery-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
@ -373,61 +373,89 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.logo-compare-overlay.active { display: flex; }
|
.logo-gallery-overlay.active { display: flex; }
|
||||||
.logo-compare-content {
|
.logo-gallery-content {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--spacing-2xl);
|
padding: var(--spacing-2xl);
|
||||||
max-width: 600px;
|
max-width: 700px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
.logo-compare-content h3 {
|
.logo-gallery-content h3 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 var(--spacing-lg) 0;
|
margin: 0 0 4px 0;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
color: var(--text-primary);
|
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;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-md);
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
.logo-compare-item {
|
.logo-gallery-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
cursor: pointer;
|
||||||
.logo-compare-item .logo-compare-label {
|
padding: var(--spacing-sm);
|
||||||
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);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
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-width: 90%;
|
||||||
max-height: 90%;
|
max-height: 90%;
|
||||||
object-fit: contain;
|
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;
|
display: flex;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.logo-compare-actions button {
|
.logo-gallery-actions button {
|
||||||
padding: var(--spacing-sm) var(--spacing-xl);
|
padding: var(--spacing-sm) var(--spacing-xl);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@ -436,19 +464,24 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
.logo-compare-actions .btn-keep {
|
.logo-gallery-actions .btn-cancel-gallery {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.logo-compare-actions .btn-keep:hover { background: var(--border); }
|
.logo-gallery-actions .btn-cancel-gallery:hover { background: var(--border); }
|
||||||
.logo-compare-actions .btn-replace {
|
.logo-gallery-actions .btn-confirm-gallery {
|
||||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||||
color: white;
|
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);
|
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 {
|
.logo-step-text {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@ -4112,27 +4145,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logo Comparison Modal -->
|
<!-- Logo Gallery Modal -->
|
||||||
<div class="logo-compare-overlay" id="logoCompareOverlay">
|
<div class="logo-gallery-overlay" id="logoGalleryOverlay">
|
||||||
<div class="logo-compare-content">
|
<div class="logo-gallery-content">
|
||||||
<h3>Porównanie logo</h3>
|
<h3>Wybierz logo</h3>
|
||||||
<div class="logo-compare-grid">
|
<p class="gallery-subtitle">Kliknij na logo, które chcesz użyć dla firmy</p>
|
||||||
<div class="logo-compare-item">
|
<div class="logo-gallery-grid" id="logoGalleryGrid">
|
||||||
<div class="logo-compare-label">Obecne logo</div>
|
<!-- Dynamically populated by JS -->
|
||||||
<div class="logo-compare-img">
|
|
||||||
<img id="logoCompareOld" src="" alt="Obecne logo">
|
|
||||||
</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>
|
||||||
<div class="logo-compare-actions">
|
<div class="logo-gallery-actions">
|
||||||
<button class="btn-keep" id="logoKeepOld">Zachowaj obecne</button>
|
<button class="btn-cancel-gallery" id="logoGalleryCancel">Anuluj</button>
|
||||||
<button class="btn-replace" id="logoUseNew">Użyj nowego</button>
|
<button class="btn-confirm-gallery" id="logoGalleryConfirm" disabled>Użyj wybranego</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -4167,13 +4190,7 @@
|
|||||||
<div class="logo-step-icon pending" id="logoStepIcon_download">
|
<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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="logo-step-text" id="logoStepText_download">Pobieram najlepszy kandydat...</span>
|
<span class="logo-step-text" id="logoStepText_download">Pobieram i konwertuję obrazy...</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-step" id="logoStep_save">
|
<div class="logo-step" id="logoStep_save">
|
||||||
<div class="logo-step-icon pending" id="logoStepIcon_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 csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
const companyId = logoBtn.dataset.companyId;
|
const companyId = logoBtn.dataset.companyId;
|
||||||
const slug = '{{ company.slug }}';
|
const slug = '{{ company.slug }}';
|
||||||
|
let selectedIndex = null;
|
||||||
|
|
||||||
async function animateSteps(data) {
|
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 = {};
|
const stepsMap = {};
|
||||||
if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; });
|
if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; });
|
||||||
|
|
||||||
@ -4689,11 +4707,62 @@ function updateLogoStep(stepId, status, message) {
|
|||||||
return resp.json();
|
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() {
|
logoBtn.addEventListener('click', async function() {
|
||||||
const overlay = document.getElementById('logoLoadingOverlay');
|
const overlay = document.getElementById('logoLoadingOverlay');
|
||||||
|
|
||||||
// Reset steps
|
// 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);
|
updateLogoStep(s, 'pending', null);
|
||||||
const el = document.getElementById('logoStep_' + s);
|
const el = document.getElementById('logoStep_' + s);
|
||||||
if (el) el.classList.remove('active', 'done');
|
if (el) el.classList.remove('active', 'done');
|
||||||
@ -4707,7 +4776,7 @@ function updateLogoStep(stepId, status, message) {
|
|||||||
try {
|
try {
|
||||||
const data = await sendLogoAction('fetch');
|
const data = await sendLogoAction('fetch');
|
||||||
await animateSteps(data);
|
await animateSteps(data);
|
||||||
await sleep(2000);
|
await sleep(1500);
|
||||||
overlay.classList.remove('active');
|
overlay.classList.remove('active');
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
@ -4717,24 +4786,10 @@ function updateLogoStep(stepId, status, message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.has_existing_logo) {
|
// Show gallery with all candidates
|
||||||
// Show comparison modal
|
buildGallery(data.candidates, data.existing_logo_ext);
|
||||||
const compareOverlay = document.getElementById('logoCompareOverlay');
|
document.getElementById('logoGalleryOverlay').classList.add('active');
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Logo fetch error:', error);
|
console.error('Logo fetch error:', error);
|
||||||
overlay.classList.remove('active');
|
overlay.classList.remove('active');
|
||||||
@ -4744,25 +4799,36 @@ function updateLogoStep(stepId, status, message) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Comparison modal: Keep old
|
// Gallery: Cancel
|
||||||
document.getElementById('logoKeepOld').addEventListener('click', async function() {
|
document.getElementById('logoGalleryCancel').addEventListener('click', async function() {
|
||||||
await sendLogoAction('cancel');
|
await sendLogoAction('cancel');
|
||||||
document.getElementById('logoCompareOverlay').classList.remove('active');
|
document.getElementById('logoGalleryOverlay').classList.remove('active');
|
||||||
showToast('Zachowano obecne logo', 'info', 3000);
|
showToast('Anulowano', 'info', 2000);
|
||||||
logoBtn.classList.remove('loading');
|
logoBtn.classList.remove('loading');
|
||||||
logoBtn.disabled = false;
|
logoBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Comparison modal: Use new
|
// Gallery: Confirm
|
||||||
document.getElementById('logoUseNew').addEventListener('click', async function() {
|
document.getElementById('logoGalleryConfirm').addEventListener('click', async function() {
|
||||||
const compareOverlay = document.getElementById('logoCompareOverlay');
|
if (selectedIndex === null) return;
|
||||||
const fileExt = compareOverlay.dataset.fileExt || 'webp';
|
this.disabled = true;
|
||||||
await sendLogoAction('confirm', { file_ext: fileExt });
|
this.textContent = 'Zapisuję...';
|
||||||
compareOverlay.classList.remove('active');
|
await sendLogoAction('confirm', { index: selectedIndex });
|
||||||
showToast('Nowe logo zapisane!', 'success', 3000);
|
document.getElementById('logoGalleryOverlay').classList.remove('active');
|
||||||
|
showToast('Logo zapisane!', 'success', 3000);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
window.location.reload();
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user