Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| feb9e4da40 | |||
| b1fde8344e | |||
| 7c0c2ea14a | |||
| 1a8dd74be6 | |||
| 61f1338405 | |||
| c80492315a | |||
| c603e5d30f | |||
| 060b9e3993 | |||
| c4e9a9294b | |||
| 4fc4ef9342 | |||
| 7c97349619 | |||
| 5d6023bd48 | |||
| cae952cd87 | |||
| 36ec4ae0ce | |||
| 97b5910c3d | |||
| 7648d93a07 | |||
| d37b45f66c | |||
| 5a8471e85c | |||
| 26f17a8bb6 | |||
| 1bf9aeb749 | |||
| 800965598c | |||
| 1747c31ba3 | |||
| 3a5ce8a6c5 | |||
| 60ff3a7116 |
@@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
.env
|
||||||
CONTEXT.md
|
CONTEXT.md
|
||||||
CONTEXT.local.md
|
CONTEXT.local.md
|
||||||
backups/
|
backups/
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-please}
|
SECRET_KEY: ${SECRET_KEY:-change-me-please}
|
||||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||||
GUNICORN_THREADS: ${GUNICORN_THREADS:-5}
|
GUNICORN_THREADS: ${GUNICORN_THREADS:-5}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
NEWS_API_TOKEN: ${NEWS_API_TOKEN:-}
|
||||||
|
TZ: ${TZ:-Europe/Moscow}
|
||||||
volumes:
|
volumes:
|
||||||
- ./matrix.db:/app/matrix.db
|
- ./matrix.db:/app/matrix.db
|
||||||
|
- ./static/news_images:/app/static/news_images
|
||||||
|
- ./static/events_images:/app/static/events_images
|
||||||
|
- ./static/css:/app/static/css
|
||||||
|
- ./templates:/app/templates
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -16,7 +24,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
ports:
|
ports:
|
||||||
- "5000:80"
|
- "5002:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 451 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,717 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Парсер новостей с mont.ru → публикует в ZKART БД.
|
||||||
|
Запуск: python3 mont_scraper.py [--all10]
|
||||||
|
"""
|
||||||
|
import re, os, sys, secrets, datetime, sqlite3, time, json
|
||||||
|
from urllib.request import urlopen, Request, build_opener, HTTPCookieProcessor
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
DB_PATH = "/home/ruslan/docker/ZKART#/matrix.db"
|
||||||
|
IMG_DIR = "/home/ruslan/docker/ZKART#/static/news_images"
|
||||||
|
BASE_URL = "https://www.mont.ru"
|
||||||
|
LIST_URL = "https://www.mont.ru/ru-ru/news?period=1"
|
||||||
|
SITE_BASE = "https://maps.4mont.ru"
|
||||||
|
TG_TOKEN = "8181219074:AAGvqWqb6t10YP4xpMOQnBq_6LrUqAFm5hM"
|
||||||
|
TG_CHAT_ID = "54986411"
|
||||||
|
MONT_EMAIL = "rgalyaviev@mont.com"
|
||||||
|
MONT_PASS = "utOgbZ09mont"
|
||||||
|
|
||||||
|
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,*/*;q=0.9"}
|
||||||
|
|
||||||
|
os.makedirs(IMG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_authenticated_opener() -> build_opener:
|
||||||
|
"""Login to mont.ru via OIDC and return an opener with auth cookies."""
|
||||||
|
jar = CookieJar()
|
||||||
|
opener = build_opener(HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
|
# Step 1: GET login → redirected to passport.mont.ru
|
||||||
|
req = Request(f"{BASE_URL}/ru-ru/account/login", headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
html = r.read().decode("utf-8", errors="replace")
|
||||||
|
login_url = r.url
|
||||||
|
|
||||||
|
form_action = re.search(r'<form[^>]+action="([^"]+)"', html)
|
||||||
|
xsrf_m = re.search(r'name="idsrv\.xsrf"[^>]+value="([^"]+)"', html)
|
||||||
|
if not form_action or not xsrf_m:
|
||||||
|
raise RuntimeError("Login form not found")
|
||||||
|
|
||||||
|
parsed = urlparse(login_url)
|
||||||
|
action_url = f"{parsed.scheme}://{parsed.netloc}{form_action.group(1)}"
|
||||||
|
|
||||||
|
# Step 2: POST credentials
|
||||||
|
post_data = urlencode({
|
||||||
|
"username": MONT_EMAIL, "password": MONT_PASS, "idsrv.xsrf": xsrf_m.group(1)
|
||||||
|
}).encode()
|
||||||
|
req2 = Request(action_url, data=post_data,
|
||||||
|
headers={**HEADERS, "Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Referer": login_url},
|
||||||
|
method="POST")
|
||||||
|
with opener.open(req2, timeout=20) as r:
|
||||||
|
html2 = r.read().decode("utf-8", errors="replace")
|
||||||
|
final_url = r.url
|
||||||
|
|
||||||
|
# Step 3: form_post with id_token back to www.mont.ru
|
||||||
|
form_action2 = re.search(r'<form[^>]+action="([^"]+)"', html2)
|
||||||
|
if form_action2:
|
||||||
|
action2 = form_action2.group(1)
|
||||||
|
hidden = re.findall(r'<input[^>]+type="hidden"[^>]+name="([^"]+)"[^>]+value="([^"]*)"', html2)
|
||||||
|
if not hidden:
|
||||||
|
hidden = re.findall(r'<input[^>]+name="([^"]+)"[^>]+type="hidden"[^>]+value="([^"]*)"', html2)
|
||||||
|
post_data3 = urlencode(dict(hidden)).encode()
|
||||||
|
req3 = Request(action2, data=post_data3,
|
||||||
|
headers={**HEADERS, "Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Referer": final_url},
|
||||||
|
method="POST")
|
||||||
|
with opener.open(req3, timeout=20) as r:
|
||||||
|
r.read()
|
||||||
|
|
||||||
|
return opener
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def tg_notify(text: str):
|
||||||
|
try:
|
||||||
|
payload = json.dumps({"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
|
||||||
|
req = Request(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST")
|
||||||
|
with urlopen(req, timeout=10):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Telegram notify failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags(html):
|
||||||
|
return unescape(re.sub(r"<[^>]+>", "", html)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TAGS = re.compile(
|
||||||
|
r'<(/?)('
|
||||||
|
r'p|br|strong|b|em|i|u|s|ul|ol|li|a|h2|h3|h4|h5|blockquote|table|thead|tbody|tr|td|th'
|
||||||
|
r')(\b[^>]*)?>', re.IGNORECASE
|
||||||
|
)
|
||||||
|
ALLOWED_ATTRS = re.compile(r'\s+(href|target|rel)="([^"]*)"', re.IGNORECASE)
|
||||||
|
DANGEROUS_PROTOCOLS = re.compile(r'^(javascript|vbscript|data):', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(html_body: str) -> str:
|
||||||
|
"""Keep formatting tags (bold, links, lists etc.) but strip everything unsafe."""
|
||||||
|
# Remove script/style blocks entirely
|
||||||
|
html_body = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', html_body, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
# Remove HTML comments
|
||||||
|
html_body = re.sub(r'<!--.*?-->', '', html_body, flags=re.DOTALL)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
pos = 0
|
||||||
|
for m in re.finditer(r'<[^>]+>', html_body):
|
||||||
|
# Text before this tag — escape it
|
||||||
|
result.append(unescape(html_body[pos:m.start()]))
|
||||||
|
pos = m.end()
|
||||||
|
tag = m.group(0)
|
||||||
|
tag_m = ALLOWED_TAGS.match(tag)
|
||||||
|
if not tag_m:
|
||||||
|
continue # strip unknown/dangerous tags
|
||||||
|
slash, name, attrs_raw = tag_m.group(1), tag_m.group(2).lower(), tag_m.group(3) or ""
|
||||||
|
if slash: # closing tag
|
||||||
|
result.append(f'</{name}>')
|
||||||
|
continue
|
||||||
|
# Build safe attribute string
|
||||||
|
safe_attrs = ""
|
||||||
|
if name == "a":
|
||||||
|
href_m = re.search(r'\bhref="([^"]*)"', attrs_raw, re.IGNORECASE)
|
||||||
|
if href_m:
|
||||||
|
href = href_m.group(1)
|
||||||
|
if not DANGEROUS_PROTOCOLS.match(href.strip()):
|
||||||
|
# Make relative mont.ru links absolute
|
||||||
|
if href.startswith("/"):
|
||||||
|
href = "https://www.mont.ru" + href
|
||||||
|
safe_attrs = f' href="{href}" target="_blank" rel="noopener"'
|
||||||
|
if name in ("br",):
|
||||||
|
result.append(f'<{name} />')
|
||||||
|
else:
|
||||||
|
result.append(f'<{name}{safe_attrs}>')
|
||||||
|
result.append(unescape(html_body[pos:]))
|
||||||
|
return "".join(result).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(opener, img_src: str):
|
||||||
|
"""Download image from mont.ru, return local relative path or None."""
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
safe_path = quote(img_src, safe="/:.-_") if img_src.startswith("/") else img_src
|
||||||
|
url = BASE_URL + safe_path if img_src.startswith("/") else safe_path
|
||||||
|
ext = os.path.splitext(img_src.split("?")[0])[1].lower() or ".png"
|
||||||
|
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||||
|
ext = ".png"
|
||||||
|
fname = f"news_{secrets.token_hex(8)}{ext}"
|
||||||
|
path = os.path.join(IMG_DIR, fname)
|
||||||
|
req = Request(url, headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=15) as resp:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(resp.read())
|
||||||
|
return f"news_images/{fname}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Image download failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def slug_from(title, slug_id):
|
||||||
|
slug = re.sub(r"[^a-z0-9а-яё]+", "-", title.lower())
|
||||||
|
slug = re.sub(r"[а-яё]", "", slug)
|
||||||
|
slug = slug.strip("-")[:50] or f"mont-news-{slug_id}"
|
||||||
|
return f"{slug}-{slug_id}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── News listing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_news_ids_from_listing(opener) -> tuple[list[str], dict[str, str]]:
|
||||||
|
"""Return (list of IDs, dict of id→img_src) from the listing page."""
|
||||||
|
req = Request(LIST_URL, headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
html = r.read().decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# Pair images with the nearest following news link (within 2000 chars)
|
||||||
|
imgs = [(m.start(), m.group(1)) for m in re.finditer(r'src="(/Content/Images/[^"]+)"', html)]
|
||||||
|
links = [(m.start(), m.group(1)) for m in re.finditer(r'href="/ru-ru/news/(\d+)"', html)]
|
||||||
|
|
||||||
|
id_to_img = {}
|
||||||
|
for img_pos, img_src in imgs:
|
||||||
|
for link_pos, art_id in links:
|
||||||
|
if link_pos > img_pos and link_pos - img_pos < 2000:
|
||||||
|
if art_id not in id_to_img:
|
||||||
|
id_to_img[art_id] = img_src
|
||||||
|
break
|
||||||
|
|
||||||
|
# Full ordered list of IDs
|
||||||
|
ids = list(dict.fromkeys(art_id for _, art_id in links))
|
||||||
|
return ids, id_to_img
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_slug_id() -> int:
|
||||||
|
"""Return the highest mont.ru article ID already in our DB."""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
rows = conn.execute("SELECT slug FROM news ORDER BY id DESC LIMIT 50").fetchall()
|
||||||
|
conn.close()
|
||||||
|
ids = []
|
||||||
|
for (slug,) in rows:
|
||||||
|
m = re.search(r"-(\d{4,})$", slug)
|
||||||
|
if m:
|
||||||
|
ids.append(int(m.group(1)))
|
||||||
|
return max(ids) if ids else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_already_saved(slug_id: str) -> bool:
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
row = conn.execute("SELECT id FROM news WHERE slug LIKE ?", (f"%-{slug_id}",)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fetch & save one article ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_and_save_article(opener, slug_id: str, listing_img: str = "") -> tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Fetch article from API, save to DB.
|
||||||
|
Returns (saved: bool, title: str, slug: str)
|
||||||
|
"""
|
||||||
|
if is_already_saved(slug_id):
|
||||||
|
print(f" [SKIP] Already exists: {slug_id}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
# Fetch article data via authenticated API
|
||||||
|
api_url = f"{BASE_URL}/ru-ru/apiMvc/news/{slug_id}"
|
||||||
|
req = Request(api_url, headers={**HEADERS, "Accept": "application/json, text/plain, */*"})
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
data = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] API fetch failed for {slug_id}: {e}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
title = strip_tags(data.get("title", "")).strip()
|
||||||
|
text_html = data.get("text", "") or ""
|
||||||
|
body = sanitize_html(text_html)
|
||||||
|
|
||||||
|
if not title or len(title) < 5:
|
||||||
|
print(f" [SKIP] No title for {slug_id}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
# Check not a 404 page
|
||||||
|
if "страница не найдена" in title.lower() or "404" in title:
|
||||||
|
print(f" [SKIP] 404 page for {slug_id}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
print(f" [FETCH] {title[:70]}...")
|
||||||
|
|
||||||
|
# Image: prefer listing image (most reliable), then API fields, then article page
|
||||||
|
img_src = listing_img or data.get("image") or data.get("img") or data.get("previewImage") or ""
|
||||||
|
image_path = None
|
||||||
|
if img_src:
|
||||||
|
image_path = download_image(opener, img_src)
|
||||||
|
if not image_path:
|
||||||
|
# Try scraping the article HTML page for an image
|
||||||
|
try:
|
||||||
|
req2 = Request(f"{BASE_URL}/ru-ru/news/{slug_id}", headers=HEADERS)
|
||||||
|
with opener.open(req2, timeout=15) as r:
|
||||||
|
pg = r.read().decode("utf-8", errors="replace")
|
||||||
|
img_m = re.search(r'src="(/Content/Images/[^"]+)"', pg)
|
||||||
|
if img_m:
|
||||||
|
image_path = download_image(opener, img_m.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
slug = slug_from(title, slug_id)
|
||||||
|
created_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO news(title, body, slug, image, published, created_at) VALUES (?,?,?,?,1,?)",
|
||||||
|
(title, body, slug, image_path, created_at)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(f" [OK] Published: {title[:70]}")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
slug = f"{slug}-{secrets.token_hex(3)}"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO news(title, body, slug, image, published, created_at) VALUES (?,?,?,?,1,?)",
|
||||||
|
(title, body, slug, image_path, created_at)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(f" [OK] Published (alt slug): {title[:70]}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
return True, title, slug
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
all10 = "--all10" in sys.argv
|
||||||
|
print(f"[{datetime.datetime.now():%Y-%m-%d %H:%M}] Logging in to mont.ru...")
|
||||||
|
errors = []
|
||||||
|
saved_count = 0
|
||||||
|
published = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
opener = make_authenticated_opener()
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Ошибка авторизации на mont.ru: {e}"
|
||||||
|
print(f"Auth error: {e}")
|
||||||
|
tg_notify(f"🚨 <b>MONT парсер</b>\n{msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Fetching news listing...")
|
||||||
|
listing_ids, id_to_img = get_news_ids_from_listing(opener)
|
||||||
|
known_max = get_max_slug_id()
|
||||||
|
probe_ids = [str(i) for i in range(known_max + 1, known_max + 6)]
|
||||||
|
all_ids = list(dict.fromkeys(listing_ids + probe_ids))
|
||||||
|
|
||||||
|
if all10:
|
||||||
|
candidate_ids = all_ids[:15]
|
||||||
|
else:
|
||||||
|
candidate_ids = [sid for sid in all_ids if not is_already_saved(sid)]
|
||||||
|
|
||||||
|
if candidate_ids:
|
||||||
|
print(f"Candidates: {candidate_ids}")
|
||||||
|
for sid in candidate_ids:
|
||||||
|
ok, title, slug = fetch_and_save_article(opener, sid, listing_img=id_to_img.get(sid, ""))
|
||||||
|
if ok:
|
||||||
|
saved_count += 1
|
||||||
|
published.append((title, slug))
|
||||||
|
|
||||||
|
if saved_count > 0:
|
||||||
|
_, refreshed_imgs = get_news_ids_from_listing(opener)
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
|
for sid in candidate_ids:
|
||||||
|
img_src = refreshed_imgs.get(sid)
|
||||||
|
if img_src:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, image FROM news WHERE slug LIKE ?", (f"%-{sid}",)
|
||||||
|
).fetchone()
|
||||||
|
if row and not row[1]:
|
||||||
|
path = download_image(opener, img_src)
|
||||||
|
if path:
|
||||||
|
conn.execute("UPDATE news SET image=? WHERE id=?", (path, row[0]))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
print("No new news.")
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Ошибка парсинга новостей: {e}"
|
||||||
|
print(f"News error: {e}")
|
||||||
|
errors.append(msg)
|
||||||
|
|
||||||
|
print(f"Done. News saved: {saved_count}")
|
||||||
|
|
||||||
|
# Hide outdated events
|
||||||
|
hidden_count = hide_outdated_events()
|
||||||
|
if hidden_count:
|
||||||
|
print(f"Hidden outdated events: {hidden_count}")
|
||||||
|
|
||||||
|
# Scrape events
|
||||||
|
ev_count, ev_published, ev_error = scrape_events(opener)
|
||||||
|
if ev_error:
|
||||||
|
errors.append(ev_error)
|
||||||
|
|
||||||
|
# Telegram: send only if something new OR errors
|
||||||
|
tg_lines = []
|
||||||
|
if saved_count > 0:
|
||||||
|
suffix = "ь" if saved_count == 1 else "и" if 2 <= saved_count <= 4 else "ей"
|
||||||
|
tg_lines.append(f"✅ <b>Новости</b>: {saved_count} новост{suffix}:")
|
||||||
|
for title, slug in published:
|
||||||
|
tg_lines.append(f' • <a href="{SITE_BASE}/news/{slug}">{title}</a>')
|
||||||
|
if ev_count > 0:
|
||||||
|
suffix = "е" if ev_count == 1 else "я" if 2 <= ev_count <= 4 else "й"
|
||||||
|
tg_lines.append(f"📅 <b>Мероприятия</b>: {ev_count} мероприяти{suffix}:")
|
||||||
|
for title, slug in ev_published:
|
||||||
|
tg_lines.append(f' • <a href="{SITE_BASE}/events/{slug}">{title}</a>')
|
||||||
|
for err in errors:
|
||||||
|
tg_lines.append(f"🚨 {err}")
|
||||||
|
if tg_lines:
|
||||||
|
tg_notify("\n".join(tg_lines))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Events scraper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
EVENTS_LIST_URL = "https://www.mont.ru/ru-ru/events?eventPeriod=1"
|
||||||
|
EVENTS_IMAGES_DIR = "/home/ruslan/docker/ZKART#/static/events_images"
|
||||||
|
os.makedirs(EVENTS_IMAGES_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_date(raw: str) -> str | None:
|
||||||
|
"""Parse various date formats to YYYY-MM-DD, return None if unparseable."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
raw = raw.strip()
|
||||||
|
# ISO format
|
||||||
|
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", raw)
|
||||||
|
if m:
|
||||||
|
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||||
|
# DD.MM.YYYY or DD/MM/YYYY
|
||||||
|
m = re.match(r"(\d{1,2})[./](\d{1,2})[./](\d{4})", raw)
|
||||||
|
if m:
|
||||||
|
return f"{m.group(3)}-{m.group(2).zfill(2)}-{m.group(1).zfill(2)}"
|
||||||
|
# D Month YYYY (Russian)
|
||||||
|
months_ru = {"января":"01","февраля":"02","марта":"03","апреля":"04","мая":"05","июня":"06",
|
||||||
|
"июля":"07","августа":"08","сентября":"09","октября":"10","ноября":"11","декабря":"12"}
|
||||||
|
m = re.match(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", raw.lower())
|
||||||
|
if m:
|
||||||
|
mon = months_ru.get(m.group(2))
|
||||||
|
if mon:
|
||||||
|
return f"{m.group(3)}-{mon}-{m.group(1).zfill(2)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_event_image(opener, img_src: str) -> str | None:
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
safe_path = quote(img_src, safe="/:.-_") if img_src.startswith("/") else img_src
|
||||||
|
url = BASE_URL + safe_path if img_src.startswith("/") else safe_path
|
||||||
|
ext = os.path.splitext(img_src.split("?")[0])[1].lower() or ".png"
|
||||||
|
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||||
|
ext = ".png"
|
||||||
|
fname = f"event_{secrets.token_hex(8)}{ext}"
|
||||||
|
path = os.path.join(EVENTS_IMAGES_DIR, fname)
|
||||||
|
req = Request(url, headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=15) as resp:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(resp.read())
|
||||||
|
return f"events_images/{fname}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Event image download failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_ids_from_listing(opener) -> tuple[list[str], dict]:
|
||||||
|
"""Use JSON API to get all upcoming events — returns more than the HTML listing."""
|
||||||
|
import json as _json
|
||||||
|
api_url = "https://www.mont.ru/ru-ru/apiMvc/events?eventPeriod=1&perPageCount=100"
|
||||||
|
req = Request(api_url, headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
data = _json.loads(r.read().decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
id_to_img = {}
|
||||||
|
id_to_date = {}
|
||||||
|
for ev in data.get("events", []):
|
||||||
|
eid = str(ev.get("eventId", ""))
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
ids.append(eid)
|
||||||
|
img = ev.get("backgroundImageUrl", "")
|
||||||
|
if img:
|
||||||
|
id_to_img[eid] = img
|
||||||
|
start = ev.get("start", "")
|
||||||
|
if start:
|
||||||
|
id_to_date[eid] = start[:10] # "2026-06-09T10:00:00" → "2026-06-09"
|
||||||
|
|
||||||
|
return ids, id_to_img, id_to_date
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_save_event(opener, eid: str, listing_img: str = "", listing_date: str = "") -> tuple[bool, str, str]:
|
||||||
|
from zkart_db_shim import is_event_saved, create_event
|
||||||
|
if is_event_saved(eid):
|
||||||
|
print(f" [SKIP] Event already exists: {eid}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
# Try API first
|
||||||
|
api_url = f"{BASE_URL}/ru-ru/apiMvc/events/{eid}"
|
||||||
|
req = Request(api_url, headers={**HEADERS, "Accept": "application/json, text/plain, */*"})
|
||||||
|
data = {}
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
data = json.loads(r.read().decode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
title = strip_tags(data.get("title", "") or data.get("name", "")).strip()
|
||||||
|
body_html = data.get("text", "") or data.get("description", "") or ""
|
||||||
|
body = sanitize_html(body_html)
|
||||||
|
|
||||||
|
# Fallback: scrape article page
|
||||||
|
if not title:
|
||||||
|
try:
|
||||||
|
req2 = Request(f"{BASE_URL}/ru-ru/events/{eid}", headers=HEADERS)
|
||||||
|
with opener.open(req2, timeout=20) as r:
|
||||||
|
pg = r.read().decode("utf-8", errors="replace")
|
||||||
|
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', pg, re.DOTALL)
|
||||||
|
if h1:
|
||||||
|
title = strip_tags(h1.group(1)).strip()
|
||||||
|
if not body:
|
||||||
|
content_m = re.search(r'<div[^>]+class="[^"]*content[^"]*"[^>]*>(.*?)</div>', pg, re.DOTALL | re.IGNORECASE)
|
||||||
|
if content_m:
|
||||||
|
body = sanitize_html(content_m.group(1))
|
||||||
|
# Try to get date from page
|
||||||
|
if not listing_date:
|
||||||
|
dm = re.search(r'(\d{1,2}[./]\d{1,2}[./]\d{4}|\d{1,2}\s+[а-яё]+\s+\d{4})', pg, re.IGNORECASE)
|
||||||
|
if dm:
|
||||||
|
listing_date = parse_event_date(dm.group(1)) or ""
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Event page fetch failed: {e}")
|
||||||
|
|
||||||
|
if not title or len(title) < 4:
|
||||||
|
print(f" [SKIP] No title for event {eid}")
|
||||||
|
return False, "", ""
|
||||||
|
|
||||||
|
print(f" [FETCH] Event: {title[:70]}...")
|
||||||
|
|
||||||
|
# Date
|
||||||
|
event_date = listing_date
|
||||||
|
if not event_date:
|
||||||
|
for field in ("date", "startDate", "start_date", "eventDate", "dateStart"):
|
||||||
|
raw = data.get(field, "")
|
||||||
|
if raw:
|
||||||
|
event_date = parse_event_date(str(raw)) or ""
|
||||||
|
if event_date:
|
||||||
|
break
|
||||||
|
if not event_date:
|
||||||
|
event_date = datetime.date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Image
|
||||||
|
img_src = listing_img or data.get("image") or data.get("img") or data.get("previewImage") or ""
|
||||||
|
image_path = None
|
||||||
|
if img_src:
|
||||||
|
image_path = download_event_image(opener, img_src)
|
||||||
|
|
||||||
|
slug_base = slug_from(title, eid)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO events(title, body, slug, image, event_date, published) VALUES (?,?,?,?,?,1)",
|
||||||
|
(title, body, slug_base, image_path, event_date)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(f" [OK] Event saved: {title[:60]} ({event_date})")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
slug_base = f"{slug_base}-{secrets.token_hex(3)}"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO events(title, body, slug, image, event_date, published) VALUES (?,?,?,?,?,1)",
|
||||||
|
(title, body, slug_base, image_path, event_date)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
time.sleep(0.4)
|
||||||
|
return True, title, slug_base
|
||||||
|
|
||||||
|
|
||||||
|
def hide_outdated_events() -> int:
|
||||||
|
"""Set published=0 for events where event_date <= today."""
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE events SET published=0 WHERE published=1 AND event_date <= date('now','localtime')"
|
||||||
|
)
|
||||||
|
count = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_page(html: str) -> dict:
|
||||||
|
"""Extract body, register_url, image_src from events-details page HTML."""
|
||||||
|
import re as _re
|
||||||
|
from html import unescape as _u
|
||||||
|
|
||||||
|
# Description: events-details__about block
|
||||||
|
body = ""
|
||||||
|
about_m = _re.search(
|
||||||
|
r'class="events-details__about[^"]*"[^>]*>.*?<div[^>]*>(.*?)</div>\s*</div>\s*</div>',
|
||||||
|
html, _re.DOTALL
|
||||||
|
)
|
||||||
|
if about_m:
|
||||||
|
body = sanitize_html(about_m.group(1))
|
||||||
|
|
||||||
|
# Registration URL
|
||||||
|
reg_m = _re.search(r'class="[^"]*register-btn[^"]*"[^>]+href="([^"]+)"', html, _re.IGNORECASE)
|
||||||
|
_raw_reg = reg_m.group(1) if reg_m else ""
|
||||||
|
if _raw_reg.startswith("/"):
|
||||||
|
_raw_reg = "https://www.mont.ru" + _raw_reg
|
||||||
|
register_url = _raw_reg
|
||||||
|
|
||||||
|
# Cover background image
|
||||||
|
cover_m = _re.search(r'events-details__background[^>]+style="background-image:\s*url\("([^&]+)"\)', html)
|
||||||
|
img_src = cover_m.group(1) if cover_m else ""
|
||||||
|
|
||||||
|
# Fallback: vendor logo
|
||||||
|
if not img_src:
|
||||||
|
logo_m = _re.search(r'events-details__logo[^>]*>.*?<img[^>]+src="([^"]+)"', html, _re.DOTALL)
|
||||||
|
if logo_m:
|
||||||
|
img_src = logo_m.group(1)
|
||||||
|
|
||||||
|
# Fallback: any /Content/Images
|
||||||
|
if not img_src:
|
||||||
|
ci_m = _re.search(r'src="(/Content/Images/[^"]+)"', html)
|
||||||
|
if ci_m:
|
||||||
|
img_src = ci_m.group(1)
|
||||||
|
|
||||||
|
# Date from events-details__dates
|
||||||
|
date_m = _re.search(r'events-details__dates[^>]*>.*?(\d{1,2}\.\d{2}\.\d{4})', html, _re.DOTALL)
|
||||||
|
date_str = parse_event_date(date_m.group(1)) if date_m else ""
|
||||||
|
|
||||||
|
return {"body": body, "register_url": register_url, "img_src": img_src, "date_str": date_str}
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_events(opener=None):
|
||||||
|
print(f"[{datetime.datetime.now():%Y-%m-%d %H:%M}] Scraping events...")
|
||||||
|
|
||||||
|
def is_event_saved(eid):
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
row = conn.execute("SELECT id FROM events WHERE slug LIKE ?", (f"%-{eid}",)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def save_event(title, body, slug, image_path, event_date, register_url):
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=15)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO events(title, body, slug, image, event_date, published, register_url) VALUES (?,?,?,?,?,1,?)",
|
||||||
|
(title, body, slug, image_path, event_date, register_url)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return slug
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
s2 = f"{slug}-{secrets.token_hex(3)}"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO events(title, body, slug, image, event_date, published, register_url) VALUES (?,?,?,?,?,1,?)",
|
||||||
|
(title, body, s2, image_path, event_date, register_url)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return s2
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if opener is None:
|
||||||
|
try:
|
||||||
|
opener = make_authenticated_opener()
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Ошибка авторизации (events): {e}"
|
||||||
|
print(f" Auth error: {e}")
|
||||||
|
return 0, [], msg
|
||||||
|
|
||||||
|
try:
|
||||||
|
ids, id_to_img, id_to_date = get_event_ids_from_listing(opener)
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Ошибка листинга мероприятий: {e}"
|
||||||
|
print(f" Listing error: {e}")
|
||||||
|
return 0, [], msg
|
||||||
|
|
||||||
|
candidates = [eid for eid in ids if not is_event_saved(eid)]
|
||||||
|
if not candidates:
|
||||||
|
print(" No new events.")
|
||||||
|
return 0, [], None
|
||||||
|
|
||||||
|
print(f" Event candidates: {candidates}")
|
||||||
|
saved_count = 0
|
||||||
|
published = []
|
||||||
|
|
||||||
|
for eid in candidates:
|
||||||
|
# Fetch full event page HTML (contains all data)
|
||||||
|
try:
|
||||||
|
req = Request(f"{BASE_URL}/ru-ru/events/{eid}", headers=HEADERS)
|
||||||
|
with opener.open(req, timeout=20) as r:
|
||||||
|
pg = r.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Could not fetch event page {eid}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = parse_event_page(pg)
|
||||||
|
body = parsed["body"]
|
||||||
|
register_url = parsed["register_url"]
|
||||||
|
img_src = parsed["img_src"] or id_to_img.get(eid, "")
|
||||||
|
event_date = parsed["date_str"] or id_to_date.get(eid, "")
|
||||||
|
|
||||||
|
# Title from h1
|
||||||
|
h1_m = re.search(r'<h1[^>]*>(.*?)</h1>', pg, re.DOTALL)
|
||||||
|
title = strip_tags(h1_m.group(1)).strip() if h1_m else ""
|
||||||
|
if not title:
|
||||||
|
title = strip_tags(re.search(r'events-details__title[^>]*>(.*?)</[^>]+>', pg, re.DOTALL).group(1)).strip() if re.search(r'events-details__title[^>]*>(.*?)</[^>]+>', pg, re.DOTALL) else ""
|
||||||
|
|
||||||
|
if not title or len(title) < 4:
|
||||||
|
print(f" [SKIP] No title for event {eid}")
|
||||||
|
continue
|
||||||
|
if "страница не найдена" in title.lower() or "404" in title:
|
||||||
|
print(f" [SKIP] 404 for event {eid}")
|
||||||
|
continue
|
||||||
|
if not body:
|
||||||
|
body = title # at minimum use title as body
|
||||||
|
|
||||||
|
if not event_date:
|
||||||
|
event_date = datetime.date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Download image
|
||||||
|
image_path = None
|
||||||
|
if img_src:
|
||||||
|
image_path = download_event_image(opener, img_src)
|
||||||
|
|
||||||
|
slug = slug_from(title, eid)
|
||||||
|
final_slug = save_event(title, body, slug, image_path, event_date, register_url)
|
||||||
|
print(f" [OK] Event: {title[:60]} ({event_date}){' +reg' if register_url else ''}")
|
||||||
|
saved_count += 1
|
||||||
|
published.append((title, final_slug))
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
return saved_count, published, None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,7 +6,7 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://app:8000;
|
proxy_pass http://app:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
:root { --b:#1f4ea3; --line:#cfe0ff; }
|
:root { --b:#1f4ea3; --line:#cfe0ff; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
|
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
|
||||||
body.ib { background:#fff1f1; }
|
body.ib { background:#eefaf3; }
|
||||||
.wrap { width:min(1600px, calc(100% - 24px)); margin:12px auto 24px; }
|
.wrap { width:min(1600px, calc(100% - 24px)); margin:12px auto 24px; }
|
||||||
.top {
|
.top {
|
||||||
background: linear-gradient(130deg, #1f4ea3, #3977df);
|
background: linear-gradient(130deg, #1f4ea3, #3977df);
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
align-items:center;
|
align-items:center;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
}
|
}
|
||||||
body.ib .top { background: linear-gradient(130deg, #9b2f3a, #c24a56); }
|
body.ib .top { background: linear-gradient(130deg, #1f7a4a, #37a96b); }
|
||||||
.scope-switch { display:flex; gap:8px; }
|
.scope-switch { display:flex; gap:8px; }
|
||||||
.scope-chip {
|
.scope-chip {
|
||||||
display:inline-block;
|
display:inline-block;
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
border:1px solid #ccdcff;
|
border:1px solid #ccdcff;
|
||||||
}
|
}
|
||||||
.scope-chip.active { background:#fff; color:#112847; }
|
.scope-chip.active { background:#fff; color:#112847; }
|
||||||
|
.top-actions { display:flex; gap:8px; align-items:center; }
|
||||||
|
.top-actions a,
|
||||||
|
.top-actions form { margin:0; }
|
||||||
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:10px; margin:12px 0; }
|
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:10px; margin:12px 0; }
|
||||||
.box {
|
.box {
|
||||||
background:#fff;
|
background:#fff;
|
||||||
@@ -54,12 +57,44 @@
|
|||||||
.pri { background:#1f4ea3; color:#fff; }
|
.pri { background:#1f4ea3; color:#fff; }
|
||||||
.warn { background:#e8eefc; color:#223963; }
|
.warn { background:#e8eefc; color:#223963; }
|
||||||
.danger { background:#ffefef; color:#8e1d1d; }
|
.danger { background:#ffefef; color:#8e1d1d; }
|
||||||
|
.alerts { display:grid; gap:8px; margin:12px 0; }
|
||||||
|
.alert { border-radius:10px; padding:10px 12px; font-weight:700; border:1px solid #cfe0ff; background:#fff; }
|
||||||
|
.alert.ok { color:#14532d; background:#ecfdf3; border-color:#bde9cb; }
|
||||||
|
.alert.error { color:#8e1d1d; background:#ffefef; border-color:#ffcaca; }
|
||||||
|
|
||||||
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
|
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
|
||||||
.list-box { max-height: 430px; overflow-y: auto; padding-right: 4px; }
|
.list-box { max-height: 430px; overflow-y: auto; padding-right: 4px; }
|
||||||
.list-box::-webkit-scrollbar { width:12px; }
|
.list-box::-webkit-scrollbar { width:12px; }
|
||||||
.list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
|
.list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
|
||||||
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; min-height: 36px; }
|
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; min-height: 36px; }
|
||||||
|
.product-item { border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; }
|
||||||
|
.product-row { display:flex; justify-content:space-between; align-items:center; gap:8px; min-height:36px; }
|
||||||
|
.product-row span { min-width:0; overflow-wrap:anywhere; }
|
||||||
|
.product-actions { display:flex; gap:8px; align-items:center; flex-shrink:0; }
|
||||||
|
.product-actions form { margin:0; }
|
||||||
|
.product-edit { display:grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1.2fr) auto; gap:8px; margin-top:8px; }
|
||||||
|
.product-edit[hidden] { display:none; }
|
||||||
|
.pending-box { margin:12px 0; }
|
||||||
|
.pending-head { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px; }
|
||||||
|
.pending-head h3 { margin:0; }
|
||||||
|
.pending-head form { margin:0; }
|
||||||
|
.pending-list { display:grid; gap:8px; }
|
||||||
|
.pending-item { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:10px; align-items:start; border:1px solid var(--line); border-radius:10px; padding:8px; }
|
||||||
|
.pending-item span { display:block; color:#49638f; font-size:12px; margin-top:2px; }
|
||||||
|
.pending-desc { margin-top:8px; padding:8px; border-radius:8px; background:#f6f9ff; white-space:pre-wrap; font-size:13px; line-height:1.45; }
|
||||||
|
.pending-actions { display:flex; gap:8px; }
|
||||||
|
.pending-actions form { margin:0; }
|
||||||
|
.admin-users-box { margin:12px 0; }
|
||||||
|
.created-admin-card { display:grid; gap:6px; margin:0 0 10px; padding:10px 12px; border:1px solid #9fd7b1; border-radius:10px; background:#ecfdf3; color:#14532d; }
|
||||||
|
.created-admin-card code { display:inline-block; padding:3px 6px; border-radius:6px; background:#fff; color:#102a1a; font-weight:800; }
|
||||||
|
.created-admin-share { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:8px; align-items:stretch; margin-top:4px; }
|
||||||
|
.created-admin-share textarea { width:100%; min-height:84px; resize:vertical; padding:9px 10px; border:1px solid #9fd7b1; border-radius:9px; font:700 13px/1.45 Manrope,sans-serif; color:#102a1a; background:#fff; }
|
||||||
|
.admin-create { display:grid; grid-template-columns:minmax(160px, 1fr) auto auto auto; gap:8px; align-items:center; }
|
||||||
|
.admin-create label { display:flex; align-items:center; gap:6px; font-weight:700; white-space:nowrap; }
|
||||||
|
.admin-users-list { display:grid; gap:6px; margin-top:10px; }
|
||||||
|
.admin-user-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:7px 8px; }
|
||||||
|
.admin-user-item span { display:grid; gap:2px; }
|
||||||
|
small { color:#60759d; font-size:11px; }
|
||||||
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; }
|
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; }
|
||||||
.matrix-scroll { overflow:auto; max-height:72vh; border:1px solid #dce7ff; border-radius:10px; }
|
.matrix-scroll { overflow:auto; max-height:72vh; border:1px solid #dce7ff; border-radius:10px; }
|
||||||
.matrix-scroll::-webkit-scrollbar,
|
.matrix-scroll::-webkit-scrollbar,
|
||||||
@@ -75,3 +110,103 @@
|
|||||||
th:first-child { z-index: 3; }
|
th:first-child { z-index: 3; }
|
||||||
td input { transform: scale(1.05); }
|
td input { transform: scale(1.05); }
|
||||||
.matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; }
|
.matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.wrap {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
margin: 8px auto 16px;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.top-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.top-actions a,
|
||||||
|
.top-actions form,
|
||||||
|
.top-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.scope-switch {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.scope-chip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.lists {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.list-box {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
.list-item {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.product-row,
|
||||||
|
.product-actions,
|
||||||
|
.pending-head,
|
||||||
|
.pending-item,
|
||||||
|
.pending-actions,
|
||||||
|
.admin-create,
|
||||||
|
.admin-user-item,
|
||||||
|
.created-admin-share {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.product-edit {
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
}
|
||||||
|
.matrix-wrap {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.matrix-scroll {
|
||||||
|
max-height: 62vh;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
input[type="text"],
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.top-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.inline-product {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.matrix-h-scroll {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.matrix-scroll::-webkit-scrollbar,
|
||||||
|
.matrix-h-scroll::-webkit-scrollbar {
|
||||||
|
height: 18px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
min-width: 145px;
|
||||||
|
}
|
||||||
|
.matrix-tip {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,37 +26,85 @@
|
|||||||
}
|
}
|
||||||
body.scope-ib {
|
body.scope-ib {
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 700px at -10% -10%, #ffb3b3 0%, transparent 58%),
|
radial-gradient(1200px 700px at -10% -10%, #b8f3cf 0%, transparent 58%),
|
||||||
radial-gradient(900px 500px at 110% -20%, #ffbeb3 0%, transparent 52%),
|
radial-gradient(900px 500px at 110% -20%, #b2efd6 0%, transparent 52%),
|
||||||
linear-gradient(160deg, #ffd7d7 0%, #ffb8b8 100%);
|
linear-gradient(160deg, #daf8e8 0%, #bdeecf 100%);
|
||||||
}
|
}
|
||||||
body.scope-ib .hero {
|
body.scope-ib .hero {
|
||||||
background:
|
background:
|
||||||
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
|
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
|
||||||
linear-gradient(125deg, #7a1f2a 0%, #b43444 55%, #d34d57 100%);
|
linear-gradient(125deg, #1f7a4a 0%, #2f9c61 55%, #47b879 100%);
|
||||||
}
|
}
|
||||||
body.scope-ib .hero::after {
|
body.scope-ib .hero::after {
|
||||||
background-color: rgba(90, 15, 28, .45);
|
background-color: rgba(10, 74, 45, .42);
|
||||||
}
|
}
|
||||||
body.scope-ib .hero::before {
|
body.scope-ib .hero::before {
|
||||||
background:
|
background:
|
||||||
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
|
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
|
||||||
rgba(74, 9, 24, .42);
|
rgba(10, 61, 38, .40);
|
||||||
}
|
}
|
||||||
body.scope-ib .mode-btn.active {
|
body.scope-ib .mode-btn.active {
|
||||||
background: linear-gradient(140deg, #9a2331, #c03d4c);
|
background: linear-gradient(140deg, #1d8d54, #2fac6d);
|
||||||
box-shadow: 0 8px 18px rgba(158, 33, 51, .35);
|
box-shadow: 0 8px 18px rgba(24, 124, 72, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IB colour overrides */
|
||||||
|
body.scope-ib .chip.active {
|
||||||
|
background: linear-gradient(140deg, #1a7a47, #27a362);
|
||||||
|
box-shadow: 0 6px 16px rgba(27,122,71,.28);
|
||||||
|
}
|
||||||
|
body.scope-ib .tag {
|
||||||
|
background: linear-gradient(135deg, #edfaf3, #d8f5e7);
|
||||||
|
color: #165c35;
|
||||||
|
border-color: #b3e8cc;
|
||||||
|
}
|
||||||
|
body.scope-ib .tag:hover,
|
||||||
|
body.scope-ib a.tag:hover {
|
||||||
|
background: linear-gradient(135deg, #d6f5e5, #c0edda);
|
||||||
|
box-shadow: 0 4px 8px rgba(22,92,53,.12);
|
||||||
|
}
|
||||||
|
body.scope-ib .count-badge {
|
||||||
|
color: #1d8d54;
|
||||||
|
background: #e2f7ed;
|
||||||
|
border-color: #b8e8cc;
|
||||||
|
}
|
||||||
|
body.scope-ib .row-card::before {
|
||||||
|
background: linear-gradient(90deg, #27a362 0%, #7dd9a8 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
body.scope-ib .active-filter {
|
||||||
|
border-color: #b8e8cc;
|
||||||
|
background: linear-gradient(145deg, #f2fbf6, #e2f7ed);
|
||||||
|
color: #165c35;
|
||||||
|
}
|
||||||
|
body.scope-ib .active-filter .remove {
|
||||||
|
background: #c8edda;
|
||||||
|
color: #1a6b40;
|
||||||
|
}
|
||||||
|
body.scope-ib .active-filter .remove:hover { background: #a8e0c0; }
|
||||||
|
body.scope-ib .card h2::before {
|
||||||
|
background: linear-gradient(180deg, #27a362, #1a7a47);
|
||||||
|
}
|
||||||
|
body.scope-ib .vtt-link.mont,
|
||||||
|
body.scope-ib .vib-link.mont {
|
||||||
|
background: linear-gradient(135deg, #1a7a47, #27a362);
|
||||||
|
}
|
||||||
|
body.scope-ib .vib-logo-text { color: #27a362; }
|
||||||
|
body.scope-ib .vendor-info-bar {
|
||||||
|
background: linear-gradient(135deg, #edfaf3 0%, #d8f5e7 100%);
|
||||||
|
border-color: #b3e8cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
width: min(1400px, calc(100% - 32px));
|
width: min(1400px, calc(100% - 32px));
|
||||||
margin: 18px auto 28px;
|
margin: 18px auto 28px;
|
||||||
|
padding-top: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-strip {
|
.brand-strip {
|
||||||
margin-bottom: 12px;
|
position: fixed;
|
||||||
display: flex;
|
top: 14px;
|
||||||
justify-content: flex-start;
|
left: 16px;
|
||||||
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@@ -106,10 +154,14 @@
|
|||||||
font-size: clamp(26px, 4.8vw, 48px);
|
font-size: clamp(26px, 4.8vw, 48px);
|
||||||
letter-spacing: .3px;
|
letter-spacing: .3px;
|
||||||
line-height: 1.02;
|
line-height: 1.02;
|
||||||
|
background: linear-gradient(115deg, #ffffff 25%, #b8d8ff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: clamp(170px, 24vw, 280px);
|
width: clamp(119px, 16.8vw, 196px);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -131,13 +183,176 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
/* ── Page layout: main content + news sidebar ── */
|
||||||
|
.page-layout {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-widget {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid #dfebff;
|
||||||
|
box-shadow: 0 10px 30px rgba(24, 56, 116, .08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-widget-head {
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid #edf3ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-all-link {
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--brand-2);
|
||||||
|
text-decoration: none; white-space: nowrap;
|
||||||
|
padding: 3px 10px; border-radius: 999px;
|
||||||
|
background: #eef4ff; border: 1px solid #c8d8f7;
|
||||||
|
transition: .15s;
|
||||||
|
}
|
||||||
|
.news-all-link:hover { background: #dbe8ff; }
|
||||||
|
|
||||||
|
.news-widget-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #234782;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-widget-head h2::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #3978e0, #1f4ea3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card {
|
||||||
|
border-bottom: 1px solid #f0f5ff;
|
||||||
|
transition: background .15s;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card:hover {
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-img-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 62px; height: 62px;
|
||||||
|
margin: 10px 0 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e8f0ff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-img {
|
||||||
|
width: 62px; height: 62px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-no-img {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-body {
|
||||||
|
padding: 10px 12px 10px 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-date {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b0c4df;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a3e79;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-2);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 2px;
|
||||||
|
transition: .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-card-btn:hover {
|
||||||
|
color: var(--brand);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-empty {
|
||||||
|
padding: 20px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ab0d0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-switch {
|
.mode-switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -146,7 +361,36 @@
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #d9e6ff;
|
border: 1px solid #d9e6ff;
|
||||||
box-shadow: 0 8px 24px rgba(26, 58, 118, .08);
|
box-shadow: 0 8px 24px rgba(26, 58, 118, .08);
|
||||||
margin-top: 12px;
|
}
|
||||||
|
|
||||||
|
.polygon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(140deg, #e05c1f, #e08c39);
|
||||||
|
box-shadow: 0 8px 18px rgba(200, 80, 20, .28);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: .18s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polygon-btn::after {
|
||||||
|
content: "↗";
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polygon-btn:hover {
|
||||||
|
background: linear-gradient(140deg, #c94f15, #d4782e);
|
||||||
|
box-shadow: 0 10px 22px rgba(200, 80, 20, .38);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
@@ -180,11 +424,23 @@
|
|||||||
|
|
||||||
.card h2 {
|
.card h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .9px;
|
letter-spacing: 1.1px;
|
||||||
color: #234782;
|
color: #234782;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.card h2::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(180deg, #3978e0, #1f4ea3);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
@@ -256,9 +512,18 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: linear-gradient(140deg, #0d7e59, #0a6648);
|
background: linear-gradient(140deg, #0d7e59, #0a6648);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 9px 14px;
|
padding: 9px 18px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: .18s ease;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
button.action:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 18px rgba(10, 100, 70, .30);
|
||||||
|
background: linear-gradient(140deg, #0e8d64, #0c7551);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
@@ -274,14 +539,65 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 14px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e2ecff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-head h3 {
|
.result-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
color: #1f3f77;
|
color: #1f3f77;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
.active-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #c9dafd;
|
||||||
|
background: linear-gradient(145deg, #e8f0ff, #dbe8ff);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.65);
|
||||||
|
}
|
||||||
|
.active-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 8px 6px 10px;
|
||||||
|
border: 1px solid #cfe0ff;
|
||||||
|
background: linear-gradient(145deg, #f8fbff, #edf4ff);
|
||||||
|
color: #21457f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.active-filter .kind {
|
||||||
|
opacity: .7;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
.active-filter .remove {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbe9ff;
|
||||||
|
color: #1f4a8f;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: .16s ease;
|
||||||
|
}
|
||||||
|
.active-filter .remove:hover {
|
||||||
|
background: #c4dbff;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rows {
|
.rows {
|
||||||
@@ -295,17 +611,46 @@
|
|||||||
background: linear-gradient(180deg, #ffffff, #f5f9ff);
|
background: linear-gradient(180deg, #ffffff, #f5f9ff);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: .2s ease;
|
||||||
|
}
|
||||||
|
.row-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #3978e0 0%, #7bb4ff 60%, transparent 100%);
|
||||||
|
}
|
||||||
|
.row-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 28px rgba(24, 56, 116, .13);
|
||||||
|
border-color: #c4d8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-card strong { color: #1a3e79; font-size: 14px; }
|
.row-card strong {
|
||||||
|
color: #1a3e79;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e8f0ff;
|
||||||
|
letter-spacing: .1px;
|
||||||
|
}
|
||||||
.tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
|
.tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.tag {
|
.tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #eaf2ff;
|
background: linear-gradient(135deg, #f0f6ff, #e4efff);
|
||||||
color: #234b89;
|
color: #1e4a8d;
|
||||||
padding: 4px 8px;
|
padding: 4px 10px;
|
||||||
border: 1px solid #d2e3ff;
|
border: 1px solid #c8dcff;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: .16s ease;
|
||||||
|
}
|
||||||
|
.tag:hover {
|
||||||
|
background: linear-gradient(135deg, #e4efff, #d4e6ff);
|
||||||
}
|
}
|
||||||
a.tag {
|
a.tag {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -316,7 +661,12 @@
|
|||||||
a.tag::after {
|
a.tag::after {
|
||||||
content: "↗";
|
content: "↗";
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: .85;
|
opacity: .8;
|
||||||
|
}
|
||||||
|
a.tag:hover {
|
||||||
|
background: linear-gradient(135deg, #daeaff, #c8ddff);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(30, 74, 141, .12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credit {
|
.credit {
|
||||||
@@ -327,23 +677,367 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
.credit .name {
|
#btn-contact-ruslan {
|
||||||
font-family: Caveat, cursive;
|
font-family: Caveat, cursive;
|
||||||
font-size: 14px;
|
font-size: 21px;
|
||||||
color: #1c3f7c;
|
color: #1c3f7c;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: opacity .15s ease, transform .15s ease;
|
||||||
}
|
}
|
||||||
.credit a {
|
#btn-contact-ruslan:hover {
|
||||||
font-size: 7px;
|
opacity: .75;
|
||||||
color: #2f5fae;
|
transform: scale(1.04);
|
||||||
text-decoration: none;
|
}
|
||||||
font-weight: 700;
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.page-layout { grid-template-columns: 1fr 260px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.brand-logo { max-width: 240px; }
|
.brand-logo { max-width: 160px; }
|
||||||
.board { grid-template-columns: 1fr; }
|
.board { grid-template-columns: 1fr; }
|
||||||
.hero { padding: 20px; }
|
.hero { padding: 20px; }
|
||||||
.credit { right: 8px; bottom: 6px; }
|
.credit { right: 8px; bottom: 6px; }
|
||||||
.credit .name { font-size: 8px; }
|
#btn-contact-ruslan { font-size: 14px; }
|
||||||
.credit a { font-size: 6px; }
|
.page-layout { grid-template-columns: 1fr; }
|
||||||
|
.news-sidebar { position: static; }
|
||||||
|
.news-list { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.news-list { flex-direction: column; }
|
||||||
|
.news-card { border-right: none; border-bottom: 1px solid #f0f5ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wrap {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
margin: 10px auto 18px;
|
||||||
|
}
|
||||||
|
.brand-strip {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
width: clamp(110px, 36vw, 170px);
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 16px 14px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(22px, 7vw, 32px);
|
||||||
|
line-height: 1.06;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.mode-switch-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.mode-switch {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.polygon-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.chip-grid {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.footer-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, .78);
|
||||||
|
border: 1px solid #dbe7ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
button.action {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.active-filters {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.active-filter {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.result-head h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.rows {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.credit {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
background: rgba(255, 255, 255, .86);
|
||||||
|
border: 1px solid #dbe6ff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
#btn-contact-ruslan { font-size: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.hero::before,
|
||||||
|
.hero::after {
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
padding: 10px 11px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
.chip-grid {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #c5d8f7 transparent;
|
||||||
|
}
|
||||||
|
.chip-grid::-webkit-scrollbar { width: 5px; }
|
||||||
|
.chip-grid::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.chip-grid::-webkit-scrollbar-thumb { background: #c5d8f7; border-radius: 4px; }
|
||||||
|
.chip-grid::-webkit-scrollbar-thumb:hover { background: #a8c4f0; }
|
||||||
|
|
||||||
|
/* Stat pills */
|
||||||
|
.stat-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(30, 74, 141, .06);
|
||||||
|
border: 1px solid #d0e0ff;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2a4e8d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stat-pill b {
|
||||||
|
color: #1a3e79;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer stats row */
|
||||||
|
#stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entrance animations */
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.hero { animation: fadeUp .35s ease both; }
|
||||||
|
.board { animation: fadeUp .45s ease .06s both; }
|
||||||
|
.footer-bar { animation: fadeUp .45s ease .09s both; }
|
||||||
|
.result { animation: fadeUp .45s ease .12s both; }
|
||||||
|
|
||||||
|
/* Chip count badge */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card-header h2 { margin: 0; }
|
||||||
|
.count-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3978e0;
|
||||||
|
background: #e8f0ff;
|
||||||
|
border: 1px solid #cddeff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Vendor tooltip popup ── */
|
||||||
|
.vendor-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 280px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(16,43,95,.22), 0 4px 16px rgba(16,43,95,.12);
|
||||||
|
border: 1px solid #dae6ff;
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(.97);
|
||||||
|
transition: opacity .18s ease, transform .18s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.vendor-tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.vtt-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: #f5f8ff;
|
||||||
|
border-bottom: 1px solid #e8f0ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
.vtt-logo img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 136px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.vtt-logo-placeholder {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #3978e0;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.vtt-body {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
.vtt-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a3e79;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.vtt-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4a5d7a;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.vtt-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.vtt-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: .15s ease;
|
||||||
|
}
|
||||||
|
.vtt-link.mont {
|
||||||
|
background: linear-gradient(135deg, #1f4ea3, #3978e0);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.vtt-link.site {
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #2a5aaa;
|
||||||
|
border: 1px solid #d0e0ff;
|
||||||
|
}
|
||||||
|
.vtt-link:hover { opacity: .85; }
|
||||||
|
/* Vendor info bar in results */
|
||||||
|
.vendor-info-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
background: linear-gradient(135deg, #f0f5ff 0%, #e8f0ff 100%);
|
||||||
|
border: 1px solid #c8d8f5;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.vendor-info-bar + .vendor-info-bar { margin-top: -8px; }
|
||||||
|
.vib-logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 240px;
|
||||||
|
height: 140px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #dae6ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.vib-logo img { max-width: 100%; max-height: 112px; object-fit: contain; }
|
||||||
|
.vib-logo-text { font-size: 36px; font-weight: 800; color: #3978e0; }
|
||||||
|
.vib-info { flex: 1; min-width: 0; }
|
||||||
|
.vib-name { font-size: 15px; font-weight: 800; color: #1a3e79; margin: 0 0 5px; }
|
||||||
|
.vib-desc { font-size: 13px; color: #4a5d7a; line-height: 1.55; margin: 0 0 10px; }
|
||||||
|
.vib-links { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.vib-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: .15s ease;
|
||||||
|
}
|
||||||
|
.vib-link.mont { background: linear-gradient(135deg, #1f4ea3, #3978e0); color: #fff; }
|
||||||
|
.vib-link.site { background: #fff; color: #2a5aaa; border: 1px solid #c8d8f5; }
|
||||||
|
.vib-link:hover { opacity: .82; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Mobile: no hover tooltips ── */
|
||||||
|
@media (hover: none), (max-width: 768px) {
|
||||||
|
.vendor-tooltip { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile: adaptive vendor info bar ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vendor-info-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.vib-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.vib-logo img { max-height: 90px; }
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">4MONT favicon</title>
|
||||||
|
<desc id="desc">A compact favicon inspired by the 4MONT logo: a blue geometric 4 and bold black M on a clean rounded square.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blue" x1="5" y1="8" x2="38" y2="58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#0C5CAD"/>
|
||||||
|
<stop offset="0.45" stop-color="#004C92"/>
|
||||||
|
<stop offset="1" stop-color="#002F62"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#001A33" flood-opacity="0.16"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="3" y="3" width="58" height="58" rx="14" fill="#FFFFFF"/>
|
||||||
|
<rect x="3.5" y="3.5" width="57" height="57" rx="13.5" fill="none" stroke="#E6EAF0"/>
|
||||||
|
|
||||||
|
<g filter="url(#softShadow)">
|
||||||
|
<!-- Stylized 4 -->
|
||||||
|
<path fill="url(#blue)" d="M7 38.7 27.4 10.2h10.4v28.5h6.3v8.9h-6.3v7.3H27.9v-7.3H7v-8.9Zm20.9 0V25.2L18 38.7h9.9Z"/>
|
||||||
|
|
||||||
|
<!-- Compact M -->
|
||||||
|
<path fill="#050505" d="M39.2 54.9V10.2h9.4l5.7 16.1 5.7-16.1h9.1v44.7h-8.7V30.2l-4.7 13.3h-3.1l-4.8-13.3v24.7h-8.6Z" transform="translate(-5.4 0)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -4,12 +4,29 @@
|
|||||||
const matrixTable = document.getElementById("matrixTable");
|
const matrixTable = document.getElementById("matrixTable");
|
||||||
const topScroll = document.getElementById("matrixHScroll");
|
const topScroll = document.getElementById("matrixHScroll");
|
||||||
const topScrollInner = document.getElementById("matrixHScrollInner");
|
const topScrollInner = document.getElementById("matrixHScrollInner");
|
||||||
|
document.addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("[data-copy-target]");
|
||||||
|
if (!button) return;
|
||||||
|
const field = document.getElementById(button.dataset.copyTarget);
|
||||||
|
if (!field) return;
|
||||||
|
field.select();
|
||||||
|
field.setSelectionRange(0, field.value.length);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(field.value);
|
||||||
|
} catch (error) {
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = "Скопировано";
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 1600);
|
||||||
|
});
|
||||||
|
|
||||||
if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return;
|
if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return;
|
||||||
|
|
||||||
let isDirty = false;
|
let isDirty = false;
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
let saveTimer = null;
|
|
||||||
let saveInFlight = false;
|
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
isDirty = true;
|
isDirty = true;
|
||||||
@@ -33,29 +50,9 @@
|
|||||||
syncing = false;
|
syncing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoSaveMatrix() {
|
|
||||||
if (saveInFlight) return;
|
|
||||||
saveInFlight = true;
|
|
||||||
try {
|
|
||||||
const formData = new FormData(matrixForm);
|
|
||||||
const response = await fetch(window.location.href, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("save failed");
|
|
||||||
isDirty = false;
|
|
||||||
} catch (error) {
|
|
||||||
} finally {
|
|
||||||
saveInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matrixForm.addEventListener("change", (event) => {
|
matrixForm.addEventListener("change", (event) => {
|
||||||
if (!(event.target && event.target.matches('input[type="checkbox"]'))) return;
|
if (!(event.target && event.target.matches('input[type="checkbox"]'))) return;
|
||||||
markDirty();
|
markDirty();
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
|
||||||
saveTimer = setTimeout(autoSaveMatrix, 250);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
matrixForm.addEventListener("submit", () => {
|
matrixForm.addEventListener("submit", () => {
|
||||||
@@ -89,4 +86,20 @@
|
|||||||
window.addEventListener("resize", updateTopScrollWidth);
|
window.addEventListener("resize", updateTopScrollWidth);
|
||||||
updateTopScrollWidth();
|
updateTopScrollWidth();
|
||||||
syncScrollFromMatrix();
|
syncScrollFromMatrix();
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-edit-product]");
|
||||||
|
if (!button) return;
|
||||||
|
const form = document.querySelector(`[data-product-edit="${button.dataset.editProduct}"]`);
|
||||||
|
if (!form) return;
|
||||||
|
form.hidden = !form.hidden;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-edit-vendor]");
|
||||||
|
if (!button) return;
|
||||||
|
const form = document.querySelector(`[data-vendor-edit="${button.dataset.editVendor}"]`);
|
||||||
|
if (!form) return;
|
||||||
|
form.hidden = !form.hidden;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -17,10 +17,87 @@
|
|||||||
clearBtn: document.getElementById("clearBtn"),
|
clearBtn: document.getElementById("clearBtn"),
|
||||||
modeInfra: document.getElementById("modeInfra"),
|
modeInfra: document.getElementById("modeInfra"),
|
||||||
modeIb: document.getElementById("modeIb"),
|
modeIb: document.getElementById("modeIb"),
|
||||||
|
resultSection: document.querySelector(".result"),
|
||||||
|
filtersSection: document.querySelector(".board"),
|
||||||
|
activeFilters: document.getElementById("activeFilters"),
|
||||||
|
vendorBadge: document.getElementById("vendorBadge"),
|
||||||
|
categoryBadge: document.getElementById("categoryBadge"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let clickAudioCtx = null;
|
let clickAudioCtx = null;
|
||||||
|
|
||||||
|
// ── Vendor tooltip (desktop / hover-capable devices only) ──
|
||||||
|
const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'vendor-tooltip';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
if (canHover) {
|
||||||
|
tooltip.addEventListener('mouseenter', () => { overTooltip = true; clearTimeout(hideTimer); });
|
||||||
|
tooltip.addEventListener('mouseleave', () => { overTooltip = false; hideTooltip(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tooltipTimer = null;
|
||||||
|
let hideTimer = null;
|
||||||
|
let overTooltip = false;
|
||||||
|
let vendorMap = {};
|
||||||
|
|
||||||
|
function buildVendorMap() {
|
||||||
|
vendorMap = {};
|
||||||
|
for (const v of state.data.vendors) {
|
||||||
|
vendorMap[v.id] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionTooltip(chipEl) {
|
||||||
|
const rect = chipEl.getBoundingClientRect();
|
||||||
|
const tw = 280, th = 260;
|
||||||
|
let left = rect.right + 10;
|
||||||
|
let top = rect.top + rect.height / 2 - th / 2;
|
||||||
|
if (left + tw > window.innerWidth - 8) left = rect.left - tw - 10;
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
if (top + th > window.innerHeight - 8) top = window.innerHeight - 8 - th;
|
||||||
|
tooltip.style.left = left + 'px';
|
||||||
|
tooltip.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip(chipEl, vendor) {
|
||||||
|
const logo = vendor.logo || '';
|
||||||
|
const desc = vendor.description || '';
|
||||||
|
const mont = vendor.mont_page || '';
|
||||||
|
const site = vendor.website || '';
|
||||||
|
|
||||||
|
let logoHtml = '';
|
||||||
|
if (logo) {
|
||||||
|
logoHtml = `<div class="vtt-logo"><img src="/static/${logo}" alt="${vendor.name}" onerror="this.parentElement.innerHTML='<span class=vtt-logo-placeholder>${vendor.name.slice(0,2).toUpperCase()}</span>'"/></div>`;
|
||||||
|
} else {
|
||||||
|
logoHtml = `<div class="vtt-logo"><span class="vtt-logo-placeholder">${vendor.name.slice(0,2).toUpperCase()}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let linksHtml = '';
|
||||||
|
if (mont) linksHtml += `<a class="vtt-link mont" href="${mont}" target="_blank" rel="noopener">MONT ↗</a>`;
|
||||||
|
if (site) linksHtml += `<a class="vtt-link site" href="${site}" target="_blank" rel="noopener">Сайт ↗</a>`;
|
||||||
|
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
${logoHtml}
|
||||||
|
<div class="vtt-body">
|
||||||
|
<div class="vtt-name">${vendor.name}</div>
|
||||||
|
${desc ? `<div class="vtt-desc">${desc}</div>` : ''}
|
||||||
|
${linksHtml ? `<div class="vtt-links">${linksHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
positionTooltip(chipEl);
|
||||||
|
tooltip.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip(immediate) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
if (immediate) { tooltip.classList.remove('visible'); return; }
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
if (!overTooltip) tooltip.classList.remove('visible');
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function playScopeClick() {
|
function playScopeClick() {
|
||||||
try {
|
try {
|
||||||
if (!clickAudioCtx) {
|
if (!clickAudioCtx) {
|
||||||
@@ -122,45 +199,70 @@
|
|||||||
const categoryQ = normalize(state.categorySearch);
|
const categoryQ = normalize(state.categorySearch);
|
||||||
|
|
||||||
el.vendorList.innerHTML = "";
|
el.vendorList.innerHTML = "";
|
||||||
|
let vi = 0;
|
||||||
for (const vendor of state.data.vendors) {
|
for (const vendor of state.data.vendors) {
|
||||||
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
|
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
|
||||||
const node = document.createElement("button");
|
const node = document.createElement("button");
|
||||||
node.className = "chip";
|
node.className = "chip";
|
||||||
|
vi++;
|
||||||
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
|
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
|
||||||
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
|
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
|
||||||
node.textContent = vendor.name;
|
node.textContent = vendor.name;
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener("click", () => {
|
||||||
if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id);
|
const wasSelected = state.selectedVendors.has(vendor.id);
|
||||||
else state.selectedVendors.add(vendor.id);
|
state.selectedVendors.clear();
|
||||||
|
if (!wasSelected) state.selectedVendors.add(vendor.id);
|
||||||
render();
|
render();
|
||||||
|
if (wasSelected) scrollAfterDeselect();
|
||||||
|
else scrollToResultsSmooth();
|
||||||
});
|
});
|
||||||
|
if (canHover) {
|
||||||
|
node.addEventListener("mouseenter", () => {
|
||||||
|
clearTimeout(tooltipTimer);
|
||||||
|
tooltipTimer = setTimeout(() => showTooltip(node, vendor), 220);
|
||||||
|
});
|
||||||
|
node.addEventListener("mouseleave", () => {
|
||||||
|
clearTimeout(tooltipTimer);
|
||||||
|
hideTooltip();
|
||||||
|
});
|
||||||
|
}
|
||||||
el.vendorList.appendChild(node);
|
el.vendorList.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
el.categoryList.innerHTML = "";
|
el.categoryList.innerHTML = "";
|
||||||
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
|
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
|
||||||
|
let ci = 0;
|
||||||
for (const category of state.data.categories) {
|
for (const category of state.data.categories) {
|
||||||
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
|
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
|
||||||
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
|
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
|
||||||
const node = document.createElement("button");
|
const node = document.createElement("button");
|
||||||
node.className = "chip";
|
node.className = "chip";
|
||||||
|
ci++;
|
||||||
if (state.selectedCategories.has(category.id)) node.classList.add("active");
|
if (state.selectedCategories.has(category.id)) node.classList.add("active");
|
||||||
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
|
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
|
||||||
node.textContent = category.name;
|
node.textContent = category.name;
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener("click", () => {
|
||||||
if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id);
|
const wasSelected = state.selectedCategories.has(category.id);
|
||||||
|
if (wasSelected) state.selectedCategories.delete(category.id);
|
||||||
else state.selectedCategories.add(category.id);
|
else state.selectedCategories.add(category.id);
|
||||||
render();
|
render();
|
||||||
|
if (wasSelected) scrollAfterDeselect();
|
||||||
|
else scrollToResultsSmooth();
|
||||||
});
|
});
|
||||||
el.categoryList.appendChild(node);
|
el.categoryList.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`;
|
const ps = visibleSets().allowedProducts.size;
|
||||||
|
el.stats.innerHTML = `<span class="stat-pill">Вендоры <b>${allowedVendors.size}</b> / ${state.data.vendors.length}</span><span class="stat-pill">Категории <b>${allowedCategories.size}</b> / ${state.data.categories.length}</span><span class="stat-pill">Продукты <b>${ps}</b> / ${state.data.products.length}</span>`;
|
||||||
|
if (el.vendorBadge) el.vendorBadge.textContent = allowedVendors.size;
|
||||||
|
if (el.categoryBadge) el.categoryBadge.textContent = allowedCategories.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResults() {
|
function renderResults() {
|
||||||
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
|
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
|
||||||
const productsById = new Map(state.data.products.map(p => [p.id, p]));
|
const productsById = new Map(state.data.products.map(p => [p.id, p]));
|
||||||
|
const vendorsById = new Map(state.data.vendors.map(v => [v.id, v]));
|
||||||
|
const isIb = state.scope === 'ib';
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const vendor of state.data.vendors) {
|
for (const vendor of state.data.vendors) {
|
||||||
@@ -174,7 +276,7 @@
|
|||||||
});
|
});
|
||||||
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
|
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
|
||||||
if (products.length === 0) continue;
|
if (products.length === 0) continue;
|
||||||
rows.push({ vendor: vendor.name, products });
|
rows.push({ vendor, products });
|
||||||
}
|
}
|
||||||
|
|
||||||
el.resultRows.innerHTML = "";
|
el.resultRows.innerHTML = "";
|
||||||
@@ -183,21 +285,62 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vendor info bars for selected vendors
|
||||||
|
if (state.selectedVendors.size > 0) {
|
||||||
|
for (const vid of state.selectedVendors) {
|
||||||
|
const v = vendorsById.get(vid);
|
||||||
|
if (!v) continue;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'vendor-info-bar';
|
||||||
|
const logoInner = v.logo
|
||||||
|
? `<img src="/static/${v.logo}" alt="${v.name}" onerror="this.parentElement.innerHTML='<span class=vib-logo-text>${v.name.slice(0,2).toUpperCase()}</span>'">`
|
||||||
|
: `<span class="vib-logo-text">${v.name.slice(0,2).toUpperCase()}</span>`;
|
||||||
|
let linksHtml = '';
|
||||||
|
if (v.slug) linksHtml += `<a class="vib-link mont" href="/vendor/${v.slug}">Подробнее →</a>`;
|
||||||
|
if (v.mont_page) linksHtml += `<a class="vib-link site" href="${v.mont_page}" target="_blank" rel="noopener">MONT ↗</a>`;
|
||||||
|
if (v.website) linksHtml += `<a class="vib-link site" href="${v.website}" target="_blank" rel="noopener">Сайт ↗</a>`;
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="vib-logo">${logoInner}</div>
|
||||||
|
<div class="vib-info">
|
||||||
|
<div class="vib-name">${v.name}</div>
|
||||||
|
${v.description ? `<div class="vib-desc">${v.description}</div>` : ''}
|
||||||
|
${linksHtml ? `<div class="vib-links">${linksHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
el.resultRows.appendChild(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "row-card";
|
card.className = "row-card";
|
||||||
const title = document.createElement("strong");
|
const title = document.createElement("strong");
|
||||||
title.textContent = row.vendor;
|
if (row.vendor.slug) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = `/vendor/${row.vendor.slug}`;
|
||||||
|
a.textContent = row.vendor.name;
|
||||||
|
a.style.cssText = "color:inherit;text-decoration:none;";
|
||||||
|
a.addEventListener("mouseenter", () => a.style.textDecoration = "underline");
|
||||||
|
a.addEventListener("mouseleave", () => a.style.textDecoration = "none");
|
||||||
|
title.appendChild(a);
|
||||||
|
} else {
|
||||||
|
title.textContent = row.vendor.name;
|
||||||
|
}
|
||||||
card.appendChild(title);
|
card.appendChild(title);
|
||||||
const tags = document.createElement("div");
|
const tags = document.createElement("div");
|
||||||
tags.className = "tags";
|
tags.className = "tags";
|
||||||
for (const product of row.products) {
|
for (const product of row.products) {
|
||||||
const hasUrl = product.url && String(product.url).trim().length > 0;
|
// IB scope: link to vendor's mont page; infra: link to product url
|
||||||
const tag = document.createElement(hasUrl ? "a" : "span");
|
let url = '';
|
||||||
|
if (isIb) {
|
||||||
|
url = row.vendor.mont_page || '';
|
||||||
|
} else {
|
||||||
|
url = (product.url && String(product.url).trim()) || '';
|
||||||
|
}
|
||||||
|
const tag = document.createElement(url ? "a" : "span");
|
||||||
tag.className = "tag";
|
tag.className = "tag";
|
||||||
tag.textContent = product.name;
|
tag.textContent = product.name;
|
||||||
if (hasUrl) {
|
if (url) {
|
||||||
tag.href = product.url;
|
tag.href = url;
|
||||||
tag.target = "_blank";
|
tag.target = "_blank";
|
||||||
tag.rel = "noopener noreferrer";
|
tag.rel = "noopener noreferrer";
|
||||||
}
|
}
|
||||||
@@ -213,12 +356,86 @@
|
|||||||
el.modeIb.classList.toggle("active", state.scope === "ib");
|
el.modeIb.classList.toggle("active", state.scope === "ib");
|
||||||
document.body.classList.toggle("scope-ib", state.scope === "ib");
|
document.body.classList.toggle("scope-ib", state.scope === "ib");
|
||||||
renderChips();
|
renderChips();
|
||||||
|
renderActiveFilters();
|
||||||
renderResults();
|
renderResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToResultsSmooth() {
|
||||||
|
if (!el.resultSection) return;
|
||||||
|
const top = Math.max(el.resultSection.getBoundingClientRect().top + window.scrollY - 10, 0);
|
||||||
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToFiltersSmooth() {
|
||||||
|
if (!el.filtersSection) return;
|
||||||
|
const top = Math.max(el.filtersSection.getBoundingClientRect().top + window.scrollY - 8, 0);
|
||||||
|
window.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTopSmooth() {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollAfterDeselect() {
|
||||||
|
if (window.innerWidth > 980) {
|
||||||
|
scrollToTopSmooth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollToFiltersSmooth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveFilters() {
|
||||||
|
if (!el.activeFilters) return;
|
||||||
|
el.activeFilters.innerHTML = "";
|
||||||
|
|
||||||
|
const vendorsById = new Map(state.data.vendors.map(v => [v.id, v.name]));
|
||||||
|
const categoriesById = new Map(state.data.categories.map(c => [c.id, c.name]));
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (const id of state.selectedVendors) {
|
||||||
|
const name = vendorsById.get(id);
|
||||||
|
if (name) items.push({ kind: "Вендор", name, id, type: "vendor" });
|
||||||
|
}
|
||||||
|
for (const id of state.selectedCategories) {
|
||||||
|
const name = categoriesById.get(id);
|
||||||
|
if (name) items.push({ kind: "Категория", name, id, type: "category" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
el.activeFilters.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.activeFilters.style.display = "flex";
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "active-filter";
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.innerHTML = `<span class="kind">${item.kind}:</span> ${item.name}`;
|
||||||
|
node.appendChild(text);
|
||||||
|
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "remove";
|
||||||
|
remove.type = "button";
|
||||||
|
remove.setAttribute("aria-label", `Убрать фильтр ${item.name}`);
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
if (item.type === "vendor") state.selectedVendors.delete(item.id);
|
||||||
|
else state.selectedCategories.delete(item.id);
|
||||||
|
render();
|
||||||
|
scrollToFiltersSmooth();
|
||||||
|
});
|
||||||
|
node.appendChild(remove);
|
||||||
|
|
||||||
|
el.activeFilters.appendChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadScopeData(scope) {
|
async function loadScopeData(scope) {
|
||||||
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
|
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
|
||||||
state.data = await res.json();
|
state.data = await res.json();
|
||||||
|
buildVendorMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -258,6 +475,7 @@
|
|||||||
el.vendorSearch.value = "";
|
el.vendorSearch.value = "";
|
||||||
el.categorySearch.value = "";
|
el.categorySearch.value = "";
|
||||||
render();
|
render();
|
||||||
|
scrollAfterDeselect();
|
||||||
});
|
});
|
||||||
|
|
||||||
el.modeInfra.addEventListener("click", () => {
|
el.modeInfra.addEventListener("click", () => {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 952 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 5.4 KiB |