From d1b8b9fa5c0fc3e1cb7f45680da486875766e6bb Mon Sep 17 00:00:00 2001 From: Soxoj <31013580+soxoj@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:41:07 +0900 Subject: [PATCH] Web interface update: live scan, settings perseistence, history, nagivation (#2925) --- .gitignore | 1 + maigret/executors.py | 43 ++- maigret/report.py | 5 +- maigret/resources/db_meta.json | 2 +- maigret/web/app.py | 576 +++++++++++++++++++++++------ maigret/web/templates/base.html | 479 +++++++++++++++++++++++- maigret/web/templates/history.html | 43 +++ maigret/web/templates/index.html | 493 +----------------------- maigret/web/templates/live.html | 162 ++++++++ maigret/web/templates/results.html | 18 +- tests/test_executors.py | 44 +++ tests/test_web.py | 531 +++++++++++++++++++++++++- 12 files changed, 1769 insertions(+), 628 deletions(-) create mode 100644 maigret/web/templates/history.html create mode 100644 maigret/web/templates/live.html diff --git a/.gitignore b/.gitignore index 41d16cd..1b35953 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ htmlcov/ # Maigret files settings.json +web_settings.json # other *.egg-info diff --git a/maigret/executors.py b/maigret/executors.py index 1e46b07..d60b90f 100644 --- a/maigret/executors.py +++ b/maigret/executors.py @@ -12,6 +12,18 @@ class AsyncioQueueGeneratorExecutor: self._results: asyncio.Queue = asyncio.Queue() self._stop_signal = object() + def _log_late_task_result(self, task): + """Done-callback for a task we stopped waiting on (timed out or the + worker itself was cancelled). Nothing else will ever retrieve this + task's outcome, so without this callback asyncio logs a noisy + "exception was never retrieved" once it finally finishes unwinding. + """ + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + self.logger.debug(f"Timed-out/cancelled check task raised: {exc}") + async def worker(self): """Process tasks from the queue and put results into the results queue.""" while True: @@ -20,16 +32,41 @@ class AsyncioQueueGeneratorExecutor: self.queue.task_done() break + query_task = None try: f, args, kwargs = task query_future = f(*args, **kwargs) query_task = asyncio.create_task(query_future) - try: - result = await asyncio.wait_for(query_task, timeout=self.timeout) - except asyncio.TimeoutError: + # Deliberately asyncio.wait(), not wait_for(): wait_for's + # cancel-on-timeout path awaits the cancelled task's own + # cleanup (e.g. closing an aiohttp/curl_cffi session) before + # returning. A site that holds its connection open without + # completing (bot protection doing exactly this) can make + # that cleanup itself take far longer than `timeout`, which + # blocks this worker — and the whole scan's progress — well + # past the configured timeout. asyncio.wait() returns the + # moment the deadline hits regardless of how long the task + # takes to actually unwind; we let that happen in the + # background instead of waiting on it here. + done, _ = await asyncio.wait({query_task}, timeout=self.timeout) + if query_task in done: + result = query_task.result() + else: + query_task.cancel() + query_task.add_done_callback(self._log_late_task_result) result = kwargs.get('default') await self._results.put(result) + except asyncio.CancelledError: + # The worker itself was cancelled (Ctrl+C / Stop button — + # see run()'s finally). Request cancellation of whatever + # query was in flight, but don't wait on it for the same + # reason as above, then let the cancellation propagate so + # this worker actually stops. + if query_task is not None and not query_task.done(): + query_task.cancel() + query_task.add_done_callback(self._log_late_task_result) + raise except Exception as e: self.logger.error(f"Error in worker: {e}", exc_info=True) finally: diff --git a/maigret/report.py b/maigret/report.py index 4711aa6..cbea7c3 100644 --- a/maigret/report.py +++ b/maigret/report.py @@ -329,7 +329,10 @@ def save_graph_report(filename: str, username_results: list, db: MaigretDatabase # Generate interactive visualization from pyvis.network import Network # type: ignore[import-untyped] - nt = Network(notebook=True, height="100vh", width="100%") + # cdn_resources="in_line": self-contained HTML, no lib/ folder written + # relative to the process cwd (pyvis's default "local" mode does that, + # which breaks when the report is served from a different directory). + nt = Network(notebook=True, height="100vh", width="100%", cdn_resources="in_line") nt.from_nx(G) nt.show(filename) diff --git a/maigret/resources/db_meta.json b/maigret/resources/db_meta.json index c4f78f6..c73ba23 100644 --- a/maigret/resources/db_meta.json +++ b/maigret/resources/db_meta.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-08-02T03:15:46Z", + "updated_at": "2026-08-02T16:00:54Z", "sites_count": 3221, "min_maigret_version": "0.5.0", "data_sha256": "7fcd1658d7a40560042ecdb819f6cc6b15b3dbe14d689c5c6c7b03e2ef146bfa", diff --git a/maigret/web/app.py b/maigret/web/app.py index 73d00fd..8d3bfa7 100644 --- a/maigret/web/app.py +++ b/maigret/web/app.py @@ -12,12 +12,16 @@ from werkzeug.exceptions import NotFound import logging import os import asyncio +import json +import queue +import uuid from datetime import datetime from threading import Thread from typing import Any, Dict import maigret import maigret.settings from maigret.checking import build_cloudflare_bypass_config +from maigret.result import MaigretCheckStatus from maigret.sites import MaigretDatabase from maigret.report import generate_report_context @@ -29,11 +33,167 @@ app.secret_key = os.getenv('FLASK_SECRET_KEY', os.urandom(24).hex()) background_jobs: Dict[str, Any] = {} job_results = {} +# Live (streaming) scan jobs, keyed by job_id. Each entry: +# {'queue': Queue, 'cancelled': bool, 'loop': event loop, 'task': asyncio task} +# ponytail: in-memory, single-process. Entries are dropped when their SSE stream +# ends; a stream that's never consumed lingers — fine for a local tool. +live_jobs: Dict[str, Any] = {} + + +class StreamNotify: + """query_notify shim: pushes each per-site check into a queue as an SSE event. + + maigret's search loop calls update() once per finished site check, which is + exactly the granularity we want to stream to the browser. + """ + + def __init__(self, event_queue, username): + self.q = event_queue + self.username = username + self.total = 0 + self.checked = 0 + self.sites = {} + # Per-site results collected so far, in the shape build_reports() + # expects. If the scan gets cancelled mid-way (Stop button), this is + # what's left to report on — otherwise every already-streamed + # 'found' event is silently discarded because the search() task + # never returns to hand back its own results dict. + self.results = {} + + def set_total(self, total): + self.total = total + self.q.put({'type': 'start', 'username': self.username, 'total': total}) + + def set_sites(self, sites): + self.sites = sites + + def update(self, result, is_similar=False): + self.checked += 1 + if not is_similar: + entry = {'status': result, 'url_user': result.site_url_user} + site = self.sites.get(result.site_name) + if site is not None: + entry['site'] = site + entry['url_main'] = site.url_main + self.results[result.site_name] = entry + if result.status == MaigretCheckStatus.CLAIMED and not is_similar: + ids = { + k: v + for k, v in (result.ids_data or {}).items() + if k != '_extractor' and isinstance(v, (str, int, float)) + } + self.q.put( + { + 'type': 'found', + 'username': result.username or self.username, + 'site': result.site_name, + 'url': result.site_url_user, + 'ids': ids, + } + ) + self.q.put( + { + 'type': 'progress', + 'checked': self.checked, + 'total': self.total, + 'site': result.site_name, + } + ) + + # No-op sinks for the rest of the notifier surface the search loop touches. + def start(self, message=None, id_type="username"): + pass + + def finish(self, message=None): + pass + + def warning(self, *a, **k): + pass + + def info(self, *a, **k): + pass + + def success(self, *a, **k): + pass + + def enrich(self, *a, **k): + pass + + # Configuration -app.config["MAIGRET_DB_FILE"] = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'resources', 'data.json') +app.config["MAIGRET_DB_FILE"] = os.path.join( + os.path.dirname(os.path.dirname(__file__)), 'resources', 'data.json' +) app.config["COOKIES_FILE"] = "cookies.txt" app.config["UPLOAD_FOLDER"] = 'uploads' app.config["REPORTS_FOLDER"] = os.path.abspath('/tmp/maigret_reports') +app.config["SETTINGS_FILE"] = "web_settings.json" + +# Search-wide defaults, editable from the Settings modal (base.html). Persisted +# to app.config["SETTINGS_FILE"] so they survive a process restart. +DEFAULT_SETTINGS = { + 'timeout': 10, + 'top_sites': 500, + 'tags': [], + 'excluded_tags': [], + 'site_list': [], + 'proxy': '', + 'tor_proxy': '', + 'i2p_proxy': '', + 'permute': False, + 'disable_recursive_search': False, + 'disable_extracting': False, + 'with_domains': False, +} + + +def load_settings(): + settings = dict(DEFAULT_SETTINGS) + path = app.config["SETTINGS_FILE"] + if os.path.exists(path): + try: + with open(path, encoding='utf-8') as f: + settings.update(json.load(f)) + except (json.JSONDecodeError, OSError) as e: + logging.error(f"Failed to load settings from {path}: {e}") + return settings + + +def save_settings(settings): + with open(app.config["SETTINGS_FILE"], 'w', encoding='utf-8') as f: + json.dump(settings, f, indent=2) + + +def parse_settings_form(form): + try: + timeout = int(form.get('timeout')) + except (TypeError, ValueError): + timeout = DEFAULT_SETTINGS['timeout'] + + try: + top_sites = int(form.get('top_sites')) + except (TypeError, ValueError): + top_sites = DEFAULT_SETTINGS['top_sites'] + + return { + 'timeout': timeout, + 'top_sites': top_sites, + 'tags': form.getlist('tags'), + 'excluded_tags': form.getlist('excluded_tags'), + 'site_list': [s.strip() for s in form.get('site', '').split(',') if s.strip()], + 'proxy': form.get('proxy', '').strip(), + 'tor_proxy': form.get('tor_proxy', '').strip(), + 'i2p_proxy': form.get('i2p_proxy', '').strip(), + 'permute': 'permute' in form, + 'disable_recursive_search': 'disable_recursive_search' in form, + 'disable_extracting': 'disable_extracting' in form, + 'with_domains': 'with_domains' in form, + } + + +@app.context_processor +def inject_settings(): + return {'web_settings': load_settings()} def setup_logger(log_level, name): @@ -42,7 +202,7 @@ def setup_logger(log_level, name): return logger -async def maigret_search(username, options): +async def maigret_search(username, options, query_notify=None): logger = setup_logger(logging.WARNING, 'maigret') try: settings = maigret.settings.Settings() @@ -80,12 +240,19 @@ async def maigret_search(username, options): logger.info(f"Found {len(sites)} sites matching the tag criteria") + if query_notify is not None and hasattr(query_notify, 'set_total'): + query_notify.set_total(len(sites)) + if query_notify is not None and hasattr(query_notify, 'set_sites'): + query_notify.set_sites(sites) + results = await maigret.search( username=username, site_dict=sites, timeout=int(options.get('timeout', 30)), logger=logger, id_type='username', + query_notify=query_notify, + no_progressbar=bool(query_notify), cookies=app.config["COOKIES_FILE"] if options.get('use_cookies') else None, is_parsing_enabled=(not options.get('disable_extracting', False)), recursive_search_enabled=( @@ -124,7 +291,95 @@ def sanitize_username_for_path(username: str) -> str: return sanitized or '_' +def build_reports(general_results, usernames, session_key): + """Write per-username CSV/JSON/PDF/HTML reports + combined graph to disk. + + Shared by the background /search job and the live SSE /api/scan job, so + both flows land on the same results.html (report buttons + profile list). + """ + os.makedirs(app.config["REPORTS_FOLDER"], exist_ok=True) + session_folder = os.path.join( + app.config["REPORTS_FOLDER"], f"search_{session_key}" + ) + os.makedirs(session_folder, exist_ok=True) + + graph_path = os.path.join(session_folder, "combined_graph.html") + maigret.report.save_graph_report( + graph_path, + general_results, + MaigretDatabase().load_from_path(app.config["MAIGRET_DB_FILE"]), + ) + + individual_reports = [] + found_count = 0 + for username, id_type, results in general_results: + safe_username = sanitize_username_for_path(username) + report_base = os.path.join(session_folder, f"report_{safe_username}") + + csv_path = f"{report_base}.csv" + json_path = f"{report_base}.json" + pdf_path = f"{report_base}.pdf" + html_path = f"{report_base}.html" + + context = generate_report_context(general_results) + + maigret.report.save_csv_report(csv_path, username, results) + maigret.report.save_json_report( + json_path, username, results, report_type='ndjson' + ) + maigret.report.save_pdf_report(pdf_path, context) + maigret.report.save_html_report(html_path, context) + + claimed_profiles = [] + for site_name, site_data in results.items(): + if ( + site_data.get('status') + and site_data['status'].status == MaigretCheckStatus.CLAIMED + ): + claimed_profiles.append( + { + 'site_name': site_name, + 'url': site_data.get('url_user', ''), + 'tags': ( + site_data.get('status').tags + if site_data.get('status') + else [] + ), + } + ) + + found_count += len(claimed_profiles) + individual_reports.append( + { + 'username': username, + 'csv_file': os.path.join( + f"search_{session_key}", f"report_{safe_username}.csv" + ), + 'json_file': os.path.join( + f"search_{session_key}", f"report_{safe_username}.json" + ), + 'pdf_file': os.path.join( + f"search_{session_key}", f"report_{safe_username}.pdf" + ), + 'html_file': os.path.join( + f"search_{session_key}", f"report_{safe_username}.html" + ), + 'claimed_profiles': claimed_profiles, + } + ) + + return { + 'status': 'completed', + 'session_folder': f"search_{session_key}", + 'graph_file': os.path.join(f"search_{session_key}", "combined_graph.html"), + 'usernames': usernames, + 'individual_reports': individual_reports, + 'found_count': found_count, + } + + def process_search_task(usernames, options, timestamp): + started_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -132,153 +387,234 @@ def process_search_task(usernames, options, timestamp): general_results = loop.run_until_complete( search_multiple_usernames(usernames, options) ) - - os.makedirs(app.config["REPORTS_FOLDER"], exist_ok=True) - session_folder = os.path.join( - app.config["REPORTS_FOLDER"], f"search_{timestamp}" - ) - os.makedirs(session_folder, exist_ok=True) - - graph_path = os.path.join(session_folder, "combined_graph.html") - maigret.report.save_graph_report( - graph_path, - general_results, - MaigretDatabase().load_from_path(app.config["MAIGRET_DB_FILE"]), - ) - - individual_reports = [] - for username, id_type, results in general_results: - safe_username = sanitize_username_for_path(username) - report_base = os.path.join(session_folder, f"report_{safe_username}") - - csv_path = f"{report_base}.csv" - json_path = f"{report_base}.json" - pdf_path = f"{report_base}.pdf" - html_path = f"{report_base}.html" - - context = generate_report_context(general_results) - - maigret.report.save_csv_report(csv_path, username, results) - maigret.report.save_json_report( - json_path, username, results, report_type='ndjson' - ) - maigret.report.save_pdf_report(pdf_path, context) - maigret.report.save_html_report(html_path, context) - - claimed_profiles = [] - for site_name, site_data in results.items(): - if ( - site_data.get('status') - and site_data['status'].status - == maigret.result.MaigretCheckStatus.CLAIMED - ): - claimed_profiles.append( - { - 'site_name': site_name, - 'url': site_data.get('url_user', ''), - 'tags': ( - site_data.get('status').tags - if site_data.get('status') - else [] - ), - } - ) - - individual_reports.append( - { - 'username': username, - 'csv_file': os.path.join( - f"search_{timestamp}", f"report_{safe_username}.csv" - ), - 'json_file': os.path.join( - f"search_{timestamp}", f"report_{safe_username}.json" - ), - 'pdf_file': os.path.join( - f"search_{timestamp}", f"report_{safe_username}.pdf" - ), - 'html_file': os.path.join( - f"search_{timestamp}", f"report_{safe_username}.html" - ), - 'claimed_profiles': claimed_profiles, - } - ) - - # save results and mark job as complete using timestamp as key - job_results[timestamp] = { - 'status': 'completed', - 'session_folder': f"search_{timestamp}", - 'graph_file': os.path.join(f"search_{timestamp}", "combined_graph.html"), - 'usernames': usernames, - 'individual_reports': individual_reports, - } + job_results[timestamp] = build_reports(general_results, usernames, timestamp) except Exception as e: logging.error(f"Error in search task for timestamp {timestamp}: {str(e)}") - job_results[timestamp] = {'status': 'failed', 'error': str(e)} + job_results[timestamp] = { + 'status': 'failed', + 'error': str(e), + 'usernames': usernames, + } finally: + job_results[timestamp]['started_at'] = started_at background_jobs[timestamp]['completed'] = True +def parse_usernames(form): + usernames_input = form.get('usernames', '').strip() + return [u.strip() for u in usernames_input.replace(',', ' ').split() if u.strip()] + + +def parse_search_options(form): + settings = load_settings() + return { + 'top_sites': settings['top_sites'], + 'timeout': settings['timeout'], + 'use_cookies': 'use_cookies' in form, + 'all_sites': form.get('mode') == 'full', + 'disable_recursive_search': settings['disable_recursive_search'], + 'disable_extracting': settings['disable_extracting'], + 'with_domains': settings['with_domains'], + 'proxy': settings['proxy'] or None, + 'tor_proxy': settings['tor_proxy'] or None, + 'i2p_proxy': settings['i2p_proxy'] or None, + 'permute': settings['permute'], + 'tags': settings['tags'], + 'excluded_tags': settings['excluded_tags'], + 'site_list': settings['site_list'], + } + + +async def _stream_search(job, usernames, options): + q = job['queue'] + general_results = [] + for username in usernames: + if job['cancelled']: + break + notify = StreamNotify(q, username.strip()) + task = asyncio.ensure_future( + maigret_search(username.strip(), options, query_notify=notify) + ) + job['task'] = task + try: + results = await task + general_results.append((username.strip(), 'username', results)) + except asyncio.CancelledError: + # The task never got to return its own results dict, but every + # site checked before cancellation already streamed a 'found' / + # 'progress' event and was captured by the notifier — report on + # that instead of throwing it away. + if notify.results: + general_results.append((username.strip(), 'username', notify.results)) + q.put({'type': 'stopped', 'username': username.strip()}) + break + except Exception as e: + if notify.results: + general_results.append((username.strip(), 'username', notify.results)) + q.put({'type': 'error', 'message': str(e), 'username': username.strip()}) + return general_results + + +def run_stream_job(job_id, usernames, options): + started_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + job = live_jobs[job_id] + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + job['loop'] = loop + general_results = [] + try: + general_results = loop.run_until_complete( + _stream_search(job, usernames, options) + ) + except Exception as e: + job['queue'].put({'type': 'error', 'message': str(e)}) + finally: + loop.close() + + # Same report files + results page as the classic /search flow, so the + # live graph is a progress view, not a replacement for the report. + done_event = {'type': 'done'} + if general_results: + try: + job_results[job_id] = build_reports(general_results, usernames, job_id) + job_results[job_id]['started_at'] = started_at + done_event['redirect'] = f"/results/search_{job_id}" + except Exception as e: + logging.error(f"Error building reports for live scan {job_id}: {str(e)}") + job['queue'].put(done_event) + + +def start_live_job(usernames, options): + job_id = uuid.uuid4().hex + live_jobs[job_id] = { + 'queue': queue.Queue(), + 'cancelled': False, + 'loop': None, + 'task': None, + } + Thread(target=run_stream_job, args=(job_id, usernames, options)).start() + return job_id + + +@app.route('/api/scan', methods=['POST']) +def scan_start(): + usernames = parse_usernames(request.form) + if not usernames: + return {'error': 'At least one username is required'}, 400 + + options = parse_search_options(request.form) + job_id = start_live_job(usernames, options) + return {'job_id': job_id} + + +@app.route('/api/scan//stream') +def scan_stream(job_id): + job = live_jobs.get(job_id) + if not job: + return "Unknown job", 404 + + def gen(): + try: + while True: + event = job['queue'].get() + yield f"data: {json.dumps(event)}\n\n" + if event.get('type') == 'done': + break + finally: + live_jobs.pop(job_id, None) + + return Response(gen(), mimetype='text/event-stream') + + +@app.route('/api/scan//stop', methods=['POST']) +def scan_stop(job_id): + job = live_jobs.get(job_id) + if not job: + return {'error': 'unknown job'}, 404 + + job['cancelled'] = True + loop = job.get('loop') + task = job.get('task') + if loop and task: + loop.call_soon_threadsafe(task.cancel) + return {'ok': True} + + @app.route('/') def index(): - # load site data for autocomplete + return render_template('index.html') + + +@app.route('/api/sites') +def api_sites(): + """Site names/URLs for the Filters site-picker datalist, fetched lazily + from the Settings modal instead of loading the DB on every page render.""" db = MaigretDatabase().load_from_path(app.config["MAIGRET_DB_FILE"]) site_options = [] - for site in db.sites: - # add main site name site_options.append(site.name) - # add URL if different from name if site.url_main and site.url_main not in site_options: site_options.append(site.url_main) + return {'sites': sorted(set(site_options))} - # sort and deduplicate - site_options = sorted(set(site_options)) - return render_template('index.html', site_options=site_options) +@app.route('/settings', methods=['POST']) +def settings_update(): + save_settings(parse_settings_form(request.form)) + flash('Settings saved.', 'success') + return redirect(request.referrer or url_for('index')) + + +@app.route('/history') +def history(): + entries = sorted( + job_results.values(), key=lambda r: r.get('started_at', ''), reverse=True + ) + return render_template('history.html', entries=entries) + + +@app.route('/live', methods=['POST']) +def live_start(): + usernames = parse_usernames(request.form) + if not usernames: + flash('At least one username is required', 'danger') + return redirect(url_for('index')) + + options = parse_search_options(request.form) + job_id = start_live_job(usernames, options) + return redirect(url_for('live_results', job_id=job_id)) + + +@app.route('/live/') +def live_results(job_id): + result = job_results.get(job_id) + if job_id not in live_jobs and not result: + flash('Unknown or expired scan session.', 'danger') + return redirect(url_for('index')) + + done_redirect = None + if result and result.get('status') == 'completed': + done_redirect = url_for('results', session_id=result['session_folder']) + + return render_template('live.html', job_id=job_id, done_redirect=done_redirect) # Modified search route @app.route('/search', methods=['POST']) def search(): - usernames_input = request.form.get('usernames', '').strip() - if not usernames_input: + usernames = parse_usernames(request.form) + if not usernames: flash('At least one username is required', 'danger') return redirect(url_for('index')) - usernames = [ - u.strip() for u in usernames_input.replace(',', ' ').split() if u.strip() - ] - # Create timestamp for this search session timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - # Get selected tags - ensure it's a list - selected_tags = request.form.getlist('tags') - excluded_tags = request.form.getlist('excluded_tags') - logging.info(f"Selected tags: {selected_tags}, Excluded tags: {excluded_tags}") - - options = { - 'top_sites': request.form.get('top_sites') or '500', - 'timeout': request.form.get('timeout') or '30', - 'use_cookies': 'use_cookies' in request.form, - 'all_sites': 'all_sites' in request.form, - 'disable_recursive_search': 'disable_recursive_search' in request.form, - 'disable_extracting': 'disable_extracting' in request.form, - 'with_domains': 'with_domains' in request.form, - 'proxy': request.form.get('proxy', None) or None, - 'tor_proxy': request.form.get('tor_proxy', None) or None, - 'i2p_proxy': request.form.get('i2p_proxy', None) or None, - 'permute': 'permute' in request.form, - 'tags': selected_tags, # Pass selected tags as a list - 'excluded_tags': excluded_tags, # Pass excluded tags as a list - 'site_list': [ - s.strip() for s in request.form.get('site', '').split(',') if s.strip() - ], - } - + options = parse_search_options(request.form) logging.info( - f"Starting search for usernames: {usernames} with tags: {selected_tags}, excluded: {excluded_tags}" + f"Starting search for usernames: {usernames} with tags: {options['tags']}, " + f"excluded: {options['excluded_tags']}" ) # Start background job diff --git a/maigret/web/templates/base.html b/maigret/web/templates/base.html index 221ca15..8910b0f 100644 --- a/maigret/web/templates/base.html +++ b/maigret/web/templates/base.html @@ -68,6 +68,89 @@ .footer a:hover { text-decoration: underline; } + + .tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 15px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.05); + margin-bottom: 20px; + } + + .tag { + display: inline-block; + padding: 5px 10px; + border-radius: 15px; + background-color: #dc3545; + color: white; + cursor: pointer; + font-size: 14px; + transition: all 0.3s ease; + user-select: none; + } + + .tag.selected { + background-color: #28a745; + } + + .tag.excluded { + background-color: #343a40; + text-decoration: line-through; + } + + .tag:hover { + transform: translateY(-2px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + } + + .hidden-select { + display: none !important; + } + + .site-input-container { + position: relative; + } + + .selected-sites { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 0; + } + + .selected-site { + background-color: #214e7b; + padding: 2px 8px; + border-radius: 12px; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 5px; + } + + .remove-site { + cursor: pointer; + color: #dc3545; + font-weight: bold; + } + + /* The header/body/footer live inside a
, which breaks the flex + chain .modal-dialog-scrollable relies on (.modal-content's direct + children stop being .modal-header/.modal-body/.modal-footer) — redo + that flex layout one level down, on the form itself. */ + #settingsModal .modal-content>form { + display: flex; + flex-direction: column; + min-height: 0; + } + + #settingsModal .modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } @@ -79,19 +162,270 @@

Maigret Web Interface

- +
+ New Search + History + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} {% block content %}{% endblock %}
+
+