PageSourceSearch

https://osintisnotacrime.com/app/search

html osintisnotacrime.com collected 2026-10-03 23:25:02 UTC 109,687 bytes, 2,396 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Username Search - OSINT IS NOT A CRIME</title>
7    <meta name="description" content="And OSINT is not a tool, but it helps">
8
9    <!-- Favicon -->
10    <link rel="icon" type="image/x-icon" href="/static/favicons/favicon.png">
11
12    <!-- Styles -->
13    <link rel="stylesheet" href="/static/css/iconoir.css">
14    <link rel="stylesheet" href="/static/css/main.css">
15    <link rel="stylesheet" href="/static/css/app-layout.css">
16    <link rel="stylesheet" href="/static/css/dark-theme.css">
17    <link rel="stylesheet" href="/static/css/theme-minitel.css">
18    <meta name="theme-color" content="#0b0c0d">
19
20    
20<script>
21    // Posé avant la première peinture : sinon la page s'affiche en sobre puis
22    // saute en Minitel, ce qui est moins un effet rétro qu'un bug.
23    (function () {
24        try {
25            var t = localStorage.getItem('rpuc.theme');
26            if (t === 'minitel') document.documentElement.setAttribute('data-theme', t);
27        } catch (e) { /* stockage bloqué : on reste en sobre */ }
28    })();
29    </script>
29
30
31    
32<!-- Page-specific styles -->
33<style>
34    .search-container {
35        max-width: 100%;
36        margin: 0 auto;
37    }
38
39    .search-box {
40        display: flex;
41        flex-wrap: wrap;
42        gap: 8px;
43        margin-bottom: 10px;
44    }
45
46    .search-box .search-input {
47        flex: 1 1 260px;
48        /* min-width:auto sur un flex item vaut la largeur intrinsèque de
49           l'input : sans ce 0, la barre débordait sous 400px. */
50        min-width: 0;
51        max-width: 520px;
52        min-height: 44px;
53        font-size: var(--font-size-md);
54    }
55
56    .search-box .btn-primary { min-height: 44px; padding: 0 20px; }
57
58    /* Sous le formulaire : l'option et le coût, sur une ligne, en retrait. */
59    .search-meta {
60        display: flex;
61        flex-wrap: wrap;
62        align-items: center;
63        gap: 6px 20px;
64        margin-bottom: 32px;
65        font-size: var(--font-size-xs);
66        color: var(--text-muted);
67    }
68
69    .search-info { color: var(--text-muted); }
70    .search-info a { color: var(--text-secondary); text-decoration: underline; text-underline-offset: 2px; }
71    .search-info a:hover { color: var(--text-primary); }
72    .search-info-sep { margin: 0 4px; color: var(--border-color-strong); }
73
74    .token-warning {
75        background: var(--error-bg);
76        border: 1px solid var(--error-border);
77        border-radius: var(--radius-md);
78        padding: 10px 15px;
79        margin-bottom: 20px;
80        font-size: var(--font-size-sm);
81        display: flex;
82        align-items: center;
83        gap: 10px;
84        color: var(--text-primary);
85    }
86
87    .token-warning a {
88        color: var(--error);
89        font-weight: bold;
90    }
91
92    .no-results {
93        text-align: center;
94        padding: 40px;
95        color: var(--text-muted);
96    }
97
98    #results-container .results-container {
99        height: auto;
100        max-height: none;
101        overflow: visible;
102    }
103
104    /* Avatar hover popover */
105    .profile-avatar-wrap {
106        position: relative;
107        flex-shrink: 0;
108        margin-right: 12px;
109    }
110    .profile-avatar-wrap .profile-avatar { margin-right: 0; }
111
112    /* Rendered in <body> so the accordion's overflow:hidden can't clip it */
113    .avatar-popover {
114        position: fixed;
115        top: 0;
116        left: 0;
117        z-index: 10001;
118        display: flex;
119        flex-direction: column;
120        align-items: center;
121        gap: 8px;
122        padding: 8px;
123        background: var(--bg-elevated);
124        border: 1px solid var(--border-color-light);
125        border-radius: var(--radius-lg);
126        box-shadow: 0 12px 40px var(--bg-scrim);
127        opacity: 0;
128        transform: scale(0.9);
129        transform-origin: bottom center;
130        pointer-events: none;
131        visibility: hidden;
132        transition: opacity 0.2s ease, transform 0.2s ease;
133    }
134    .avatar-popover.flip-below { transform-origin: top center; }
135    .avatar-popover.visible {
136        opacity: 1;
137        transform: scale(1);
138        pointer-events: auto;
139        visibility: visible;
140    }
141    .avatar-popover img {
142        width: 160px; height: 160px;
143        object-fit: cover;
144        border-radius: var(--radius-md);
145    }
146    .avatar-popover-actions {
147        display: flex; gap: 4px;
148    }
149    .avatar-action-btn {
150        display: flex;
151        align-items: center;
152        justify-content: center;
153        gap: 4px;
154        padding: 4px 10px;
155        border: none;
156        border-radius: var(--radius-sm);
157        background: var(--tint-medium);
158        color: var(--text-muted);
159        cursor: pointer;
160        font-size: var(--font-size-xs);
161        transition: all 0.15s;
162    }
163    .avatar-action-btn:hover { background: var(--accent); color: var(--text-on-accent); }
164    .avatar-action-btn i { font-size: 0.85rem; }
165
166    .enrich-toggle {
167        display: inline-flex;
168        align-items: center;
169        gap: 7px;
170        cursor: pointer;
171        font-size: var(--font-size-xs);
172        color: var(--text-secondary);
173    }
174    .enrich-toggle input { width: 14px; height: 14px; }
175    .enrich-cost {
176        font-family: var(--font-mono);
177        color: var(--text-muted);
178    }
179
180    /* Cross-site summary */
181    .search-summary {
182        padding: 18px 20px 12px;
183        margin-bottom: 28px;
184        background: var(--bg-surface);
185        border: 1px solid var(--border-color);
186        border-radius: var(--radius-lg);
187        box-shadow: var(--shadow-sm);
188    }
189    .search-summary-header {
190        display: flex; align-items: baseline; gap: 8px;
191        flex-wrap: wrap;
192        font-size: 0.6875rem; font-weight: 700;
193        text-transform: uppercase; letter-spacing: 0.08em;
194        color: var(--text-secondary);
195        margin-bottom: 16px;
196    }
197    .search-summary-header i { color: var(--accent); font-size: 1rem; align-self: center; }
198    .search-summary-header .summary-sub {
199        margin-left: auto;
200        font-weight: 400;
201        text-transform: none; letter-spacing: 0;
202        font-size: var(--font-size-xs);
203        color: var(--text-muted);
204    }
205    .search-summary-grid {
206        display: grid;
207        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
208        gap: 18px 32px;
209    }
210    .summary-facet-label {
211        display: flex; align-items: baseline; gap: 6px;
212        font-size: var(--font-size-xs);
213        font-weight: 500;
214        color: var(--text-secondary);
215        margin-bottom: 8px;
216    }
217    .summary-facet-label .facet-count {
218        color: var(--text-muted);
219        font-family: var(--font-mono);
220        font-size: 0.7rem;
221    }
222    .summary-chips { display: flex; flex-wrap: wrap; gap: 5px; }
223    .summary-chip {
224        display: inline-flex; align-items: baseline; gap: 5px;
225        max-width: 100%;
226        padding: 3px 9px;
227        border: 1px solid var(--border-color-light);
228        border-radius: var(--radius-sm);
229        background: var(--bg-surface-hover);
230        color: var(--text-primary);
231        font-size: var(--font-size-xs);
232        cursor: pointer;
233        transition: border-color 0.15s, background 0.15s;
234    }
235    .summary-chip:hover {
236        background: var(--bg-surface-active);
237        border-color: var(--border-color-strong);
238    }
239    .summary-chip:focus-visible,
240    .summary-chip a:focus-visible {
241        outline: 2px solid var(--accent);
242        outline-offset: 2px;
243    }
244    .summary-chip .chip-text {
245        overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
246    }
247    .summary-chip .chip-n {
248        flex-shrink: 0;
249        color: var(--text-muted);
250        font-family: var(--font-mono); font-size: 0.72rem;
251    }
252    .summary-chip a { color: inherit; text-decoration: none; }
253    .summary-chip.is-overflow { display: none; }
254    .summary-facet.expanded .summary-chip.is-overflow { display: inline-flex; }
255    .summary-facet.expanded .summary-chip-more { display: none; }
256    .summary-chip-more {
257        background: transparent;
258        border-style: dashed;
259        color: var(--text-secondary);
260    }
261    .summary-chip-accent {
262        background: var(--accent-bg);
263        border-color: var(--accent-border);
264        color: var(--text-primary);
265    }
266    .summary-chip-accent:hover { background: var(--accent-bg); border-color: var(--accent); }
267
268    /* Not found accordion */
269    /* Collapsible section accordion (generic) */
270    .section-accordion {
271        grid-column: 1 / -1;
272        margin-top: 8px;
273    }
274    .section-accordion-header {
275        display: flex; align-items: center; gap: 8px;
276        padding: 10px 0;
277        margin-bottom: 14px;
278        border-bottom: 1px solid var(--border-color);
279        cursor: pointer; user-select: none;
280        font-size: 0.6875rem; font-weight: 700;
281        text-transform: uppercase; letter-spacing: 0.08em;
282        transition: color 0.15s;
283        color: var(--text-secondary);
284    }
285    .section-accordion-header:hover { color: var(--text-primary); }
286    .section-accordion-header .sa-chevron { color: var(--accent); }
287    .section-accordion-header:focus-visible {
288        outline: 2px solid var(--accent);
289        outline-offset: -2px;
290    }
291    /* Volontairement pas en flex : les noeuds de texte autour du compteur
292       redeviendraient des items et récolteraient le gap du parent. */
293    .section-accordion-header .sa-title { display: inline; min-width: 0; }
294    .section-accordion-header .sa-title i { vertical-align: -0.1em; margin-right: 4px; }
295    .section-accordion-header .sa-chevron {
296        display: inline-block; width: 12px; height: 12px;
297        flex-shrink: 0; transition: transform 0.25s ease; position: relative;
298    }
299    .section-accordion-header .sa-chevron::before {
300        content: ''; position: absolute; top: 2px; left: 3px;
301        width: 5px; height: 5px;
302        border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
303        transform: rotate(-45deg);
304    }
305    .section-accordion.open .sa-chevron { transform: rotate(90deg); }
306    .section-accordion-body {
307        max-height: 0; overflow: hidden;
308        transition: max-height 0.35s ease;
309    }
310    .section-accordion.open .section-accordion-body {
311        max-height: 10000px;
312    }
313    .section-accordion-body-inner {
314        padding: 0 0 8px;
315    }
316
317    /* Legacy aliases for not-found */
318    .not-found-accordion { border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--tint-neutral); overflow: hidden; }
319    .not-found-accordion-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px; cursor: pointer; user-select: none; color: var(--text-muted); font-weight: 600; font-size: var(--font-size-md); transition: background 0.15s; }
320    .not-found-accordion-header:hover { background: var(--tint-medium); }
321    .not-found-accordion-header .nf-chevron { display: inline-block; width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.25s ease; position: relative; }
322    .not-found-accordion-header .nf-chevron::before { content: ''; position: absolute; top: 4px; left: 4px; width: 7px; height: 7px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); }
323    .not-found-accordion.open .nf-chevron { transform: rotate(90deg); }
324    .not-found-accordion-body { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; }
325    .not-found-accordion.open .not-found-accordion-body { max-height: 5000px; }
326    .not-found-accordion-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; padding: 0 16px 16px; }
327</style>
328
329
330    
331</head>
332<body>
333    <div class="app-container">
334
335        <!-- ===== HEADER ===== -->
336        <header class="app-header">
337            <button type="button" class="app-nav-toggle" id="app-nav-toggle"
338                    aria-label="Open navigation" aria-expanded="false" aria-controls="app-sidebar">
339                <i class="iconoir-menu"></i>
340            </button>
341            <a href="/app/" class="app-header-logo">
342                <svg class="app-header-logo-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 219.3 106.1"><g><path d="M17.1,40.2c0-3.4.6-6.5,1.7-9.3,1.2-2.8,2.8-5.2,4.8-7.1,2.1-2,4.5-3.5,7.3-4.5,2.8-1,5.9-1.6,9.3-1.6s6.4.5,9.3,1.6c2.8,1,5.3,2.6,7.3,4.5,2.1,2,3.7,4.3,4.8,7.1s1.7,5.9,1.7,9.3-.6,6.5-1.7,9.3-2.8,5.2-4.8,7.1c-2.1,2-4.5,3.5-7.3,4.5-2.8,1-5.9,1.6-9.3,1.6s-6.4-.5-9.3-1.6c-2.8-1-5.3-2.6-7.3-4.5-2.1-2-3.7-4.3-4.8-7.1-1.1-2.8-1.7-5.9-1.7-9.3ZM26.9,40.2c0,2,.3,3.9.9,5.5.6,1.7,1.5,3.1,2.7,4.4,1.2,1.2,2.6,2.2,4.2,2.9,1.6.7,3.5,1,5.5,1s3.8-.3,5.5-1c1.6-.7,3-1.6,4.2-2.9,1.2-1.2,2.1-2.7,2.7-4.4.6-1.7.9-3.5.9-5.5s-.3-3.8-.9-5.5c-.6-1.7-1.5-3.2-2.7-4.4-1.2-1.2-2.6-2.2-4.2-2.9-1.6-.7-3.5-1-5.5-1s-3.8.3-5.5,1c-1.6.7-3,1.6-4.2,2.9-1.2,1.2-2.1,2.7-2.7,4.4-.6,1.7-.9,3.5-.9,5.5Z" fill="currentColor"/><path d="M91,29.2c-.8-1-1.8-1.7-3.1-2.1-1.3-.5-2.6-.7-3.7-.7s-1.4,0-2.1.2c-.7.2-1.4.4-2.1.8-.6.3-1.2.8-1.6,1.4-.4.6-.6,1.3-.6,2.1,0,1.3.5,2.3,1.5,3,1,.7,2.2,1.3,3.7,1.8,1.5.5,3.1,1,4.8,1.5s3.3,1.2,4.8,2.1c1.5.9,2.7,2.1,3.7,3.6,1,1.5,1.5,3.5,1.5,6s-.4,4.5-1.3,6.2c-.9,1.8-2.1,3.2-3.6,4.4-1.5,1.2-3.3,2-5.3,2.6-2,.6-4.1.8-6.3.8s-5.4-.4-7.7-1.3c-2.4-.8-4.6-2.2-6.7-4.1l6.7-7.4c1,1.3,2.2,2.3,3.7,3,1.5.7,3,1.1,4.6,1.1s1.5,0,2.3-.3c.8-.2,1.5-.5,2.1-.8.6-.4,1.1-.8,1.5-1.4.4-.6.6-1.2.6-2,0-1.3-.5-2.3-1.5-3.1-1-.7-2.2-1.4-3.7-1.9-1.5-.5-3.1-1-4.8-1.6-1.7-.5-3.4-1.2-4.8-2.1-1.5-.9-2.7-2.1-3.7-3.5-1-1.5-1.5-3.4-1.5-5.8s.5-4.3,1.4-6.1c.9-1.7,2.1-3.2,3.6-4.4,1.5-1.2,3.3-2,5.2-2.6,2-.6,4-.9,6.1-.9s4.8.3,7,1c2.3.7,4.3,1.8,6.1,3.5l-6.5,7.1Z" fill="currentColor"/><path d="M104.4,18.8h9.4v42.9h-9.4V18.8Z" fill="currentColor"/><path d="M122.4,18.8h12.8l18.2,29.8h.1v-29.8h9.4v42.9h-12.3l-18.7-30.5h-.1v30.5h-9.4V18.8Z" fill="currentColor"/><path d="M180.1,27.1h-12.2v-8.4h33.9v8.4h-12.2v34.5h-9.4V27.1Z" fill="currentColor"/></g><g><path d="M0,0v106.1h219.3V0H0ZM209.3,10v59.2H10V10h199.3ZM14.5,94.2v-17.6h3.1v17.6h-3.1ZM28.6,88c-.4-.3-.9-.6-1.5-.9-.6-.2-1.3-.5-2-.7-.7-.2-1.4-.5-2-.9-.6-.4-1.1-.9-1.5-1.5-.4-.6-.6-1.5-.6-2.5s.2-1.7.6-2.4.9-1.2,1.5-1.7c.6-.4,1.3-.8,2.1-1,.8-.2,1.6-.3,2.4-.3s1.8.1,2.7.4c.9.3,1.7.7,2.4,1.4l-2.3,2.4c-.3-.5-.8-.8-1.3-1.1-.6-.2-1.1-.3-1.8-.3s-.7,0-1.1.1c-.3,0-.6.2-.9.4-.3.2-.5.4-.7.7-.2.3-.2.6-.2,1,0,.6.2,1.1.6,1.4.4.3.9.6,1.5.8.6.2,1.3.5,2,.7.7.2,1.4.5,2,.9.6.4,1.1.9,1.5,1.5.4.6.6,1.5.6,2.5s-.2,1.8-.5,2.5-.8,1.3-1.4,1.8c-.6.5-1.3.8-2.1,1-.8.2-1.6.3-2.5.3s-2.1-.2-3.2-.5-1.9-1-2.6-1.8l2.4-2.3c.4.6.9,1,1.5,1.4.6.3,1.3.5,2,.5s.7,0,1.1-.1.7-.2,1-.4c.3-.2.5-.5.7-.8.2-.3.3-.7.3-1.
3421,0-.6-.2-1.1-.6-1.5ZM43.2,94.2v-17.6h4.2l8.5,13.1h0v-13.1h3.1v17.6h-4l-8.7-13.5h0v13.5h-3.1ZM62,85.5c0-1.4.2-2.7.7-3.8.5-1.2,1.1-2.1,1.9-2.9s1.8-1.4,2.9-1.9c1.1-.4,2.4-.7,3.7-.7,1.4,0,2.6.2,3.8.6s2.1,1,3,1.9,1.5,1.8,1.9,2.9c.5,1.1.7,2.4.7,3.8s-.2,2.6-.7,3.8c-.5,1.1-1.1,2.1-1.9,2.9-.8.8-1.8,1.4-3,1.9-1.1.5-2.4.7-3.8.7-1.4,0-2.6-.2-3.7-.7-1.1-.4-2.1-1.1-2.9-1.9-.8-.8-1.5-1.8-1.9-2.9-.5-1.1-.7-2.4-.7-3.8ZM81.9,79.5v-2.8h13.9v2.8h-5.4v14.8h-3.1v-14.8h-5.4ZM103.3,94.2l7.7-17.6h2.7l7.6,17.6h-3.6l-1.6-4h-7.6l-1.6,4h-3.5ZM133.4,87.9c.3.8.7,1.5,1.2,2.1.5.6,1.2,1,1.9,1.4s1.6.5,2.5.5,1.4-.2,2.1-.5c.7-.3,1.3-.9,1.9-1.6l2.6,1.8c-.8,1.1-1.8,1.9-2.9,2.4-1.1.5-2.4.7-3.7.7s-2.6-.2-3.7-.7c-1.1-.4-2.1-1.1-2.9-1.9-.8-.8-1.5-1.8-1.9-2.9-.5-1.1-.7-2.4-.7-3.8s.2-2.7.7-3.8c.5-1.2,1.1-2.1,1.9-2.9s1.8-1.4,2.9-1.9c1.1-.4,2.4-.7,3.7-.7s2.3.2,3.3.6c1,.4,2,1.1,2.8,2.2l-2.4,1.8c-.6-.7-1.3-1.2-1.9-1.4-.6-.2-1.2-.3-1.8-.3-.9,0-1.7.2-2.5.5s-1.4.8-1.9,1.3c-.5.6-.9,1.2-1.2,2s-.4,1.6-.4,2.5.1,1.8.4,2.6ZM160.9,94.2h-3.8l-4.1-7.5h-2.3v7.5h-3.1v-17.6h6.1c.8,0,1.7,0,2.4.2.8.2,1.5.4,2.1.8.6.4,1.1.9,1.5,1.5.4.6.5,1.5.5,2.5s-.4,2.3-1.1,3.2c-.7.9-1.7,1.4-3,1.6l4.7,7.8ZM163.3,94.2v-17.6h3.1v17.6h-3.1ZM186.2,79.6l-5.3,14.6h-2.2l-5.3-14.6h0v14.6h-3v-17.6h4.8l4.7,12.3,4.8-12.3h4.7v17.6h-3v-14.6h0ZM204.5,83.8v2.8h-8.1v4.8h9v2.8h-12.1v-17.6h11.7v2.8h-8.5v4.3h8.1Z" fill="currentColor"/><polygon points="112.3 80.4 109.5 87.5 115 87.5 112.3 80.4" fill="currentColor"/><path d="M67,90c.5.6,1.2,1,1.9,1.4s1.6.5,2.5.5,1.7-.2,2.5-.5,1.4-.8,1.9-1.4c.5-.6.9-1.3,1.2-2.1s.4-1.7.4-2.6-.1-1.7-.4-2.5-.7-1.4-1.2-2c-.5-.6-1.2-1-1.9-1.3s-1.6-.5-2.5-.5-1.7.2-2.5.5-1.4.8-1.9,1.3c-.5.6-.9,1.2-1.2,2s-.4,1.6-.4,2.5.1,1.8.4,2.6.7,1.5,1.2,2.1Z" fill="currentColor"/><path d="M155.9,79.7c-.3-.2-.7-.3-1.2-.3s-.8,0-1.2,0h-2.7v4.8h2.4c.4,0,.9,0,1.3,0,.4,0,.9-.1,1.2-.3.4-.2.7-.4.9-.7.2-.3.3-.8.3-1.4s-.1-.9-.3-1.2c-.2-.3-.5-.6-.8-.7Z" fill="currentColor"/></g></svg>
343                <span class="app-header-logo-text">OSINT IS NOT A CRIME</span>
344            </a>
345
346            
347            <nav class="app-header-nav">
348                
349                    
350                    <a href="/app/" class="app-header-link " title="Home">
351                        <i class="iconoir-home"></i>
352                        <span class="app-header-link-label">Home</span>
353                    </a>
354                    
355                
356                    
357                
358                    
359                    <a href="/app/news" class="app-header-link " title="News">
360                        <i class="iconoir-journal"></i>
361                        <span class="app-header-link-label">News</span>
362                    </a>
363                    
364                
365                    
366                    <a href="/app/help" class="app-header-link " title="Help">
367                        <i class="iconoir-chat-bubble-question"></i>
368                        <span class="app-header-link-label">Help</span>
369                    </a>
370                    
371                
372            </nav>
373
374            <div class="app-header-spacer"></div>
375
376            
377
378            <button type="button" class="theme-switch" id="theme-switch"
379                    role="switch" aria-checked="false" aria-label="Minitel mode"
380                    title="Mode Minitel">
381                <span class="theme-switch-track"><span class="theme-switch-knob"></span></span>
382                <span class="theme-switch-label">3615</span>
383            </button>
384
385            <div class="app-header-user">
386                
387                    <a href="/app/login" class="app-header-icon-link" title="Login">
388                        <i class="iconoir-log-in"></i>
389                    </a>
390                    <a href="/app/register" class="btn btn-primary btn-sm">Register</a>
391                
392            </div>
393        </header>
394
395        <!-- ===== MAIN ===== -->
396        <main class="app-main">
397
398            <!-- Sidebar — text nav, no icons -->
399            <aside class="app-sidebar" id="app-sidebar">
400                <nav class="sidebar-nav">
401                    <div class="sidebar-section">
402                        <div class="sidebar-section-label">Tools</div>
403                        
404                        <a href="/app/search"
405                           class="sidebar-link active">
406                            <span class="sidebar-link-name">Rhino UserSearch</span>
407                            
408                            <span class="sidebar-link-badge">1 tk</span>
409                            
410                        </a>
411                        
412                        <a href="/app/watchlist"
413                           class="sidebar-link ">
414                            <span class="sidebar-link-name">Watchlist</span>
415                            
416                            <span class="sidebar-link-badge">1 tk</span>
417                            
418                        </a>
419                        
420                        <a href="/app/rhinoscraper"
421                           class="sidebar-link ">
422                            <span class="sidebar-link-name">RhinoScraper</span>
423                            
424                            <span class="sidebar-link-badge">5 tk</span>
425                            
426                        </a>
427                        
428                        <a href="/app/geoloc"
429                           class="sidebar-link ">
430                            <span class="sidebar-link-name">Geoloc</span>
431                            
432                            <span class="sidebar-link-badge">10 tk</span>
433                            
434                        </a>
435                        
436                        <a href="/app/exif"
437                           class="sidebar-link ">
438                            <span class="sidebar-link-name">EXIF</span>
439                            
440                            <span class="sidebar-link-badge free">Free</span>
441                            
442                        </a>
443                        
444                        <a href="/app/opendoc"
445                           class="sidebar-link ">
446                            <span class="sidebar-link-name">OpenDoc</span>
447                            
448                            <span class="sidebar-link-badge free">Free</span>
449                            
450                        </a>
451                        
452                    </div>
453                    <div class="sidebar-section sidebar-section-mobile">
454                        <div class="sidebar-section-label">Menu</div>
455                        
456                            
457                            <a href="/app/" class="sidebar-link ">
458                                <span class="sidebar-link-name">Home</span>
459                            </a>
460                            
461                        
462                            
463                        
464                            
465                            <a href="/app/news" class="sidebar-link ">
466                                <span class="sidebar-link-name">News</span>
467                            </a>
468                            
469                        
470                            
471                            <a href="/app/help" class="sidebar-link ">
472                                <span class="sidebar-link-name">Help</span>
473                            </a>
474                            
475                        
476                    </div>
477                </nav>
478            </aside>
479            <div class="app-sidebar-scrim" id="app-sidebar-scrim" hidden></div>
480
481            <!-- Content -->
482            <section class="app-content">
483                
484<div class="app-window">
485    <div class="app-window-titlebar">
486        <span class="app-window-title">Username Search</span>
487    </div>
488    <div class="app-window-content">
489        <div class="search-container">
490
491            <div class="tab-bar" role="tablist">
492                <button type="button" class="tab-btn active" role="tab" aria-selected="true"
493                        data-tab="main">Search</button>
494                <button type="button" class="tab-btn" role="tab" aria-selected="false"
495                        data-tab="saved">
496                    Saved locally <span class="tab-count" data-vault-count></span>
497                </button>
498            </div>
499
500            <div class="tab-panel" data-tab-panel="main" role="tabpanel">
501
502            <!-- Token Warning (if low balance) -->
503            
504
505            <!-- Search Form -->
506            <form id="search-form" class="search-box" onsubmit="return startSearch(event)">
507                <input type="text"
508                       id="username-input"
509                       class="app-input search-input"
510                       placeholder="Username to look up"
511                       required
512                       autocomplete="off"
513                       minlength="3"
514                       maxlength="50">
515                <button type="submit" class="btn btn-primary" id="search-btn">
516                    Search
517                </button>
518                <button type="button" class="btn btn-ghost" id="load-test-btn" style="display:none" onclick="loadTestJSON()">
519                    <i class="iconoir-upload"></i> Load JSON
520                </button>
521                <input type="file" id="test-json-input" accept=".json" style="display:none">
522            </form>
523
524            <div class="search-meta">
525                <label id="enrich-toggle" class="enrich-toggle" title="Find additional mentions, profiles, and email references across the web">
526                    <input type="checkbox" id="enrich-checkbox">
527                    <span>Enrich with web traces</span>
528                    <span class="enrich-cost">+1 tk</span>
529                </label>
530                <span class="search-info">
531                    1 token per search
532                    <span class="search-info-sep">·</span>
533                    <a href="/app/sites">221 platforms</a>
534                    <span class="search-info-sep">·</span>
535                    Balance login required
536                </span>
537            </div>
538
539            <!-- Progress (same structure as index.html) -->
540            <div id="progress-container" class="search-progress">
541                <div class="search-progress-bar">
542                    <div id="search-progress-bar" class="search-progress-fill"></div>
543                </div>
544                <p id="search-progress-message" class="search-progress-message">Initializing search...</p>
545            </div>
546
547            <!-- Cross-site summary (built once the search completes) -->
548            <div id="search-summary" class="search-summary" style="display:none;"></div>
549
550            <!-- Results -->
551            <div id="results-container" style="display: none;">
552                <div class="results-header">
553                    <div>
554                        <h2 class="results-title" id="results-title">Results</h2>
555                        <p class="results-timestamp" id="results-timestamp"></p>
556                    </div>
557                    <div class="results-header-actions">
558                        <div id="export-buttons">
559                            <button class="btn btn-export" onclick="exportJSON()"><i class="iconoir-code"></i> JSON</button>
560                            <button class="btn btn-export" onclick="exportPDF()"><i class="iconoir-folder"></i> PDF</button>
561                            <button class="btn btn-export" onclick="exportZeroneurone()" title="Export pour Zeroneurone"><i class="iconoir-brain-warning"></i> Zeroneurone</button>
562                            <button class="btn btn-export" id="save-local-btn" onclick="saveSearchLocally()" title="Chiffrer et garder dans ce navigateur uniquement"><i class="iconoir-lock"></i> Save locally</button>
563                            
564                        </div>
565                        <div class="selection-bar" id="selection-bar" style="display:none;">
566                            <label><input type="checkbox" id="select-all-cb" checked onchange="toggleSelectAll(this.checked)"> Select all</label>
567                            <span class="selection-count" id="selection-count"></span>
568                        </div>
569                    </div>
570                </div>
571
572                <div class="results-container">
573                    <div id="results-cards" class="results-cards">
574                        <!-- Profile cards will be inserted here -->
575                    </div>
576                </div>
577            </div>
578
579            <!-- Dork Enrichment Results -->
580            <div id="dork-results-container" style="display:none;margin-top:15px;"></div>
581
582            <!-- No Results -->
583            <div id="no-results" class="no-results" style="display: none;">
584                <p>No profiles found for this username.</p>
585            </div>
586
587            </div><!-- /tab main -->
588
589            <div class="tab-panel" data-tab-panel="saved" role="tabpanel" hidden></div>
590
591        </div>
592    </div>
593</div>
594
595            </section>
596
597        </main>
598
599        <!-- ===== FOOTER ===== -->
600        <footer class="app-footer">
601            <span>&copy; OSINT IS NOT A CRIME 2024</span>
602            
603            <span class="sep">&middot;</span>
604            <a href="https://manufacture-osint.fr" target="_blank">provided by Manufacture Française d&#39;Osint</a>
605            
606            <span class="sep">&middot;</span>
607            <a href="/page/help" >Help &amp; FAQ</a>
608            
609            <span class="sep">&middot;</span>
610            <a href="/page/about" >About</a>
611            
612            <span class="sep">&middot;</span>
613            <a href="/page/legal" >Legal</a>
614            
615        </footer>
616
617    </div>
618
619    <!-- Global alert modal -->
620    <div id="app-alert-modal" class="app-modal-overlay" onclick="if(event.target===this)appModalClose()">
621        <div class="app-window">
622            <div class="app-window-titlebar">
623                <span class="app-window-title" id="app-alert-title">Alert</span>
624            </div>
625            <div class="app-window-content">
626                <div class="app-alert-icon" id="app-alert-icon"></div>
627                <div class="app-alert-message" id="app-alert-message"></div>
628                <div class="app-alert-actions" id="app-alert-actions">
629                    <button class="btn btn-primary btn-sm" onclick="appModalClose()">OK</button>
630                </div>
631            </div>
632        </div>
633    </div>
634
635    
635<script>
636    // Tiroir de navigation mobile : sous 640px la sidebar et la nav d'en-tête
637    // sont masquées, ce bouton est le seul accès aux autres outils.
638    (function() {
639        const toggle = document.getElementById('app-nav-toggle');
640        const sidebar = document.getElementById('app-sidebar');
641        const scrim = document.getElementById('app-sidebar-scrim');
642        if (!toggle || !sidebar || !scrim) return;
643
644        function setOpen(open) {
645            sidebar.classList.toggle('open', open);
646            scrim.hidden = !open;
647            toggle.setAttribute('aria-expanded', String(open));
648            toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation');
649        }
650        toggle.addEventListener('click', () => setOpen(!sidebar.classList.contains('open')));
651        scrim.addEventListener('click', () => setOpen(false));
652        sidebar.addEventListener('click', (e) => { if (e.target.closest('a')) setOpen(false); });
653        document.addEventListener('keydown', (e) => { if (e.key === 'Escape') setOpen(false); });
654        window.addEventListener('resize', () => { if (window.innerWidth > 640) setOpen(false); });
655    })();
656
657    // Bascule de thème. Le seul état persisté est le nom du thème.
658    (function () {
659        const btn = document.getElementById('theme-switch');
660        if (!btn) return;
661        const root = document.documentElement;
662
663        function apply(theme, persist) {
664            const on = theme === 'minitel';
665            if (on) root.setAttribute('data-theme', 'minitel');
666            else root.removeAttribute('data-theme');
667            btn.setAttribute('aria-checked', String(on));
668            btn.classList.toggle('is-on', on);
669            if (persist) {
670                try { localStorage.setItem('rpuc.theme', on ? 'minitel' : 'sober'); }
671                catch (e) { /* stockage bloqué : la bascule reste valable pour la session */ }
672            }
673        }
674
675        let current = 'sober';
676        try { current = localStorage.getItem('rpuc.theme') || 'sober'; } catch (e) { /* ignore */ }
677        apply(current, false);
678
679        btn.addEventListener('click', function () {
680            apply(root.getAttribute('data-theme') === 'minitel' ? 'sober' : 'minitel', true);
681        });
682    })();
683
684    const _appAlertIcons = {error:'✕',success:'✓',warning:'⚠',info:'ℹ'};
685    const _appAlertTitles = {error:'Error',success:'Success',warning:'Warning',info:'Information'};
686    function appModal(message, type) {
687        type = type || 'info';
688        const modal = document.getElementById('app-alert-modal');
689        const icon = document.getElementById('app-alert-icon');
690        const msg = document.getElementById('app-alert-message');
691        const title = document.getElementById('app-alert-title');
692        const actions = document.getElementById('app-alert-actions');
693        title.textContent = _appAlertTitles[type] || 'Alert';
694        icon.textContent = _appAlertIcons[type] || 'ℹ';
695        icon.className = 'app-alert-icon ' + type;
696        msg.textContent = message;
697        actions.innerHTML = '<button class="btn btn-primary btn-sm" onclick="appModalClose()">OK</button>';
698        modal.classList.add('active');
699    }
700    function appConfirm(message, onConfirm, type) {
701        type = type || 'warning';
702        const modal = document.getElementById('app-alert-modal');
703        const icon = document.getElementById('app-alert-icon');
704        const msg = document.getElementById('app-alert-message');
705        const title = document.getElementById('app-alert-title');
706        const actions = document.getElementById('app-alert-actions');
707        title.textContent = _appAlertTitles[type] || 'Confirm';
708        icon.textContent = _appAlertIcons[type] || '⚠';
709        icon.className = 'app-alert-icon ' + type;
710        msg.textContent = message;
711        actions.innerHTML = '<button class="btn btn-outline btn-sm" onclick="appModalClose()">Cancel</button>' +
712            '<button class="btn btn-primary btn-sm" id="app-confirm-btn">OK</button>';
713        document.getElementById('app-confirm-btn').onclick = function() { appModalClose(); onConfirm(); };
714        modal.classList.add('active');
715    }
716    function appModalClose() {
717        document.getElementById('app-alert-modal').classList.remove('active');
718    }
719    document.addEventListener('keydown', function(e) {
720        if (e.key === 'Escape') appModalClose();
721    });
722    </script>
722
723
724    
724<script src="/static/js/socket.io.min.js"></script>
724
725    
726<!-- jsPDF for PDF export -->
727<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
vendor: 1 bytes, line 727
727
728<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
728
729<!-- Zeroneurone export -->
730<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
vendor: 1 bytes, line 730
730
731<script src="/static/js/zeroneurone-export.js"></script>
731
732<!-- Coffre local chiffré : aucun appel réseau, voir l'en-tête du fichier -->
733<script src="/static/js/local-vault.js"></script>
vendor: 1 bytes, line 733
733
734<script src="/static/js/vault-ui.js"></script>
734
735
736<script>
737    // Browser ID management
738    function generateBrowserId() {
739        const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
740        let id = '';
741        for (let i = 0; i < 16; i++) {
742            id += chars.charAt(Math.floor(Math.random() * chars.length));
743        }
744        return id;
745    }
746
747    function getBrowserId() {
748        const cookies = document.cookie.split(';');
749        for (let i = 0; i < cookies.length; i++) {
750            const cookie = cookies[i].trim();
751            if (cookie.startsWith('rpuc_browser_id=')) {
752                return cookie.substring('rpuc_browser_id='.length);
753            }
754        }
755        const newId = generateBrowserId();
756        const expiry = new Date();
757        expiry.setDate(expiry.getDate() + 30);
758        document.cookie = `rpuc_browser_id=${newId}; expires=${expiry.toUTCString()}; path=/; SameSite=Lax`;
759        return newId;
760    }
761
762    // Socket.IO connection
763    const socket = io({
764        reconnection: true,
765        reconnectionAttempts: Infinity,
766        reconnectionDelay: 1000,
767        reconnectionDelayMax: 5000,
768        timeout: 180000,
769        pingTimeout: 60000,
770        pingInterval: 25000
771    });
772
773    let sessionToken = '';
774    let isSearching = false;
775    let currentUsername = '';
776    let searchResults = {};
777    let resultsCount = 0;
778    // Affichage live : sites déjà rendus + sections créées à la volée
779    let renderedSites = new Set();
780    let incrementalSections = { multi: null, single: null };
781    let lastBlockedSites = [];
782    let lastNotFoundSites = [];
783    let lastDorkData = null;
784
785    // Les booléens bruts s'affichaient « false », et les grands nombres se
786    // lisaient mal (142883). La valeur brute reste dans data-full pour la copie.
787    function formatStatValue(value) {
788        if (typeof value === 'boolean') return value ? 'yes' : 'no';
789        if (value === 'true') return 'yes';
790        if (value === 'false') return 'no';
791        if (typeof value === 'number' && Number.isFinite(value) && Math.abs(value) >= 10000) {
792            return value.toLocaleString('en-US').replace(/,/g, '\u202f');
793        }
794        return value;
795    }
796
797    // HTML attribute escaping for safe data-* attributes
798    function escAttr(str) {
799        return String(str).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
800    }
801
802    // Liens rel="me" : identités que la personne déclare elle-même sur son profil
803    // (convention IndieWeb). Affichés à part des stats, ce sont des pivots d'enquête.
804    function buildRelMeHtml(...sources) {
805        const links = [];
806        const seen = new Set();
807        for (const src of sources) {
808            if (!src || !Array.isArray(src.rel_me_links)) continue;
809            for (const url of src.rel_me_links) {
810                const key = String(url).replace(/\/+$/, '').toLowerCase();
811                if (seen.has(key)) continue;
812                seen.add(key);
813                links.push(String(url));
814            }
815        }
816        if (!links.length) return '';
817        // Pas de troncature en JS : le CSS s'en charge (text-overflow), ce qui
818        // s'adapte à la largeur réelle de la carte au lieu de couper à l'aveugle.
819        const items = links.map(url => {
820            const label = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
821            return `<a href="${escAttr(url)}" target="_blank" rel="noopener noreferrer" class="copyable" title="${escAttr(url)}" data-full="${escAttr(url)}">${escAttr(label)}</a>`;
822        }).join('');
823        return `<div class="profile-relme">
824            <div class="profile-relme-label" title='rel="me" — identités déclarées par la personne elle-même'>linked identities <span class="profile-relme-count">${links.length}</span></div>
825            <div class="profile-relme-links">${items}</div>
826        </div>`;
827    }
828
829    socket.on('connect', function() {
830        console.log('Connected to server');
831        const browserId = getBrowserId();
832        socket.emit('register_browser', { browser_id: browserId });
833    });
834
835    socket.on('session_token', function(data) {
836        sessionToken = data.token;
837        document.cookie = `jwt_token=${sessionToken}; path=/; max-age=3600; SameSite=Strict`;
838    });
839
840    socket.on('disconnect', function(reason) {
841        console.log('Disconnected:', reason);
842        if (isSearching) {
843            isSearching = false;
844            searchError('Connection lost during search. Please try again.');
845        }
846    });
847
848    // Handle token errors
849    socket.on('search_error', function(data) {
850        console.log('Search error:', data);
851        isSearching = false;
852        hideProgress();
853        document.getElementById('search-btn').disabled = false;
854
855        if (data.login_required) {
856            appConfirm('Login required to search. Cost: ' + data.token_cost + ' token(s). Would you like to login now?', function() {
857                window.location.href = '/app/login?next=' + encodeURIComponent(window.location.pathname);
858            });
859        } else if (data.insufficient_tokens) {
860            appConfirm('Insufficient tokens. Required: ' + data.required + ', Available: ' + data.balance + '. Would you like to buy more tokens?', function() {
861                window.location.href = '/app/account/tokens';
862            });
863        } else {
864            appModal('Error: ' + data.error, 'error');
865        }
866    });
867
868    // Handle token balance updates
869    socket.on('token_update', function(data) {
870        console.log('Token update:', data);
871        // Update displayed balance
872        const balanceText = document.querySelector('.search-info');
873        if (balanceText && data.tokens_balance !== undefined) {
874            balanceText.innerHTML = `1 token per search
875                <span class="search-info-sep">·</span>
876                <a href="/app/sites">221 platforms</a>
877                <span class="search-info-sep">·</span>
878                Balance ${data.tokens_balance}`;
879        }
880    });
881
882    // Handle search output from terminal_output event
883    socket.on('terminal_output', function(data) {
884        if (!isSearching) return;
885
886        if (data.type === 'text') {
887            const text = cleanAnsiCodes(data.data);
888
889            // Update progress message
890            if (text.includes('Searching') || text.includes('Checking') ||
891                text.includes('Processing') || text.includes('Starting') ||
892                text.match(/\[\d+\/\d+\]/)) {
893                updateProgressMessage(text);
894            }
895
896            // Update progress bar based on completion messages
897            const progressMatch = text.match(/\[(\d+)\/(\d+)\]/);
898            if (progressMatch) {
899                const current = parseInt(progressMatch[1]);
900                const total = parseInt(progressMatch[2]);
901                const percentage = (current / total) * 100;
902                const progressBar = document.getElementById('search-progress-bar');
903                if (progressBar) {
904                    progressBar.style.width = percentage + '%';
905                }
906            }
907        }
908    });
909
910    // Handle a single site hit pushed live during the search
911    socket.on('site_result', function(data) {
912        if (!data || !data.site_name || !data.result) return;
913        renderSiteResult(data.site_name, data.result);
914    });
915
916    // Handle search completion
917    socket.on('search_complete', function(data) {
918        console.log('Search complete:', data);
919        isSearching = false;
920
921        // Set progress to 100%
922        const progressBar = document.getElementById('search-progress-bar');
923        if (progressBar) {
924            progressBar.style.width = '100%';
925        }
926        updateProgressMessage('Search complete!');
927        document.getElementById('search-btn').disabled = false;
928
929        // Fetch full results from Redis
930        if (data.redis_key || data.task_id) {
931            const taskId = data.redis_key || data.task_id;
932            fetchResultsFromRedis(taskId);
933            // Fetch dork results if available
934            if (data.has_dorks) {
935                fetchDorkResults(taskId);
936            }
937        } else {
938            hideProgress();
939            if (renderedSites.size === 0) {
940                document.getElementById('no-results').style.display = 'block';
941            }
942        }
943    });
944
945    function startSearch(event) {
946        event.preventDefault();
947
948        if (isSearching) {
949            appModal('A search is already in progress. Please wait.', 'warning');
950            return false;
951        }
952
953        // Pas de toLowerCase : on préserve la casse pour les sites sensibles
954        // (Wikipedia...). Les sites insensibles la gèrent eux-mêmes.
955        const username = document.getElementById('username-input').value.trim();
956        if (!username || username.length < 3) {
957            appModal('Username must be at least 3 characters long.', 'error');
958            return false;
959        }
960
961        // Reset UI
962        currentUsername = username;
963        searchResults = {};
964        resultsCount = 0;
965        renderedSites = new Set();
966        incrementalSections = { multi: null, single: null };
967        showProgress();
968        document.getElementById('results-container').style.display = 'none';
969        document.getElementById('no-results').style.display = 'none';
970        document.getElementById('results-cards').innerHTML = '';
971        document.getElementById('dork-results-container').style.display = 'none';
972        document.getElementById('dork-results-container').innerHTML = '';
973        document.getElementById('selection-bar').style.display = 'none';
974        document.getElementById('search-summary').style.display = 'none';
975        document.getElementById('search-summary').innerHTML = '';
976        summaryReady = false;
977        expandedFacets = new Set();
978        lastBlockedSites = [];
979        lastNotFoundSites = [];
980        lastDorkData = null;
981        document.getElementById('search-btn').disabled = true;
982        isSearching = true;
983
984        // Update results title
985        document.getElementById('results-title').textContent = `Results for "${username}"`;
986        document.getElementById('results-timestamp').textContent = `Search started at ${new Date().toLocaleTimeString()}`;
987
988        updateProgressMessage('Starting search for ' + username + '...');
989
990        const enrich = document.getElementById('enrich-checkbox').checked;
991
992        // Send search command via SocketIO
993        socket.emit('command', {
994            command: 'search ' + username,
995            token: sessionToken,
996            browser_id: getBrowserId(),
997            enrich: enrich
998        });
999
1000        return false;
1001    }
1002
1003    function showProgress() {
1004        const progressContainer = document.getElementById('progress-container');
1005        if (progressContainer) {
1006            progressContainer.classList.add('active');
1007        }
1008        const progressBar = document.getElementById('search-progress-bar');
1009        if (progressBar) {
1010            progressBar.style.width = '0%';
1011        }
1012    }
1013
1014    function hideProgress() {
1015        const progressContainer = document.getElementById('progress-container');
1016        if (progressContainer) {
1017            progressContainer.classList.remove('active');
1018        }
1019    }
1020
1021    function cleanAnsiCodes(text) {
1022        return text.replace(/\x1b\[[0-9;]*m/g, '').trim();
1023    }
1024
1025    function updateProgressMessage(message) {
1026        const msgElement = document.getElementById('search-progress-message');
1027        if (msgElement) {
1028            msgElement.textContent = cleanAnsiCodes(message);
1029        }
1030    }
1031
1032    function fetchResultsFromRedis(taskId) {
1033        console.log('Fetching results from Redis:', taskId);
1034        fetch('/api/redis-download/' + taskId)
1035            .then(response => response.json())
1036            .then(data => {
1037                console.log('Redis results:', data);
1038                hideProgress();
1039                processResults(data);
1040            })
1041            .catch(error => {
1042                console.error('Error fetching results:', error);
1043                hideProgress();
1044                document.getElementById('no-results').style.display = 'block';
1045            });
1046    }
1047
1048    // ===== MASONRY =====
1049    // Une grille CSS aligne le bas de chaque rangée sur la carte la plus haute.
1050    // Avec des fiches allant de zéro à sept stats, ça laisse de grands blancs.
1051    // On donne donc à chaque carte un nombre de pistes de 1px égal à sa hauteur,
1052    // ce qui la fait remonter dans le trou laissé par sa voisine.
1053    const MASONRY_GAP = 16;
1054    let masonryFrame = null;
1055
1056    function layoutMasonry() {
1057        masonryFrame = null;
1058        document.querySelectorAll('.results-cards.masonry-grid').forEach(grid => {
1059            // La classe .masonry n'est posée qu'ici : si ce code ne tourne pas,
1060            // la grille reste une grille normale au lieu de laisser les cartes
1061            // se chevaucher dans des pistes de 1px.
1062            grid.classList.add('masonry');
1063            const items = Array.from(grid.children);
1064            items.forEach(el => { el.style.gridRowEnd = ''; });
1065            // Toutes les lectures groupées après toutes les écritures :
1066            // un seul reflow pour la grille entière, pas un par carte.
1067            const heights = items.map(el => el.getBoundingClientRect().height);
1068            items.forEach((el, i) => {
1069                // Pistes de 1px : l'écart entre deux cartes vaut exactement
1070                // MASONRY_GAP, sans l'arrondi d'une piste plus grosse.
1071                el.style.gridRowEnd = 'span ' + (Math.ceil(heights[i]) + MASONRY_GAP);
1072            });
1073        });
1074    }
1075
1076    function scheduleMasonry() {
1077        if (masonryFrame) return;
1078        masonryFrame = requestAnimationFrame(layoutMasonry);
1079    }
1080
1081    window.addEventListener('resize', scheduleMasonry);
1082    // Les polices personnalisées changent la hauteur des cartes en arrivant.
1083    if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleMasonry);
1084
1085    // Get (or lazily create) the collapsible section for a given kind.
1086    // Multi-profile section is always kept before the single-profile one.
1087    function getOrCreateSection(kind) {
1088        if (incrementalSections[kind]) return incrementalSections[kind];
1089
1090        const titleBase = kind === 'multi' ? 'USER SEARCH API' : 'SINGLE PROFILE SITES';
1091        const sec = createCollapsibleSection(`${titleBase} (<span class="sec-live-count">0</span>)`, true);
1092        sec.body.classList.add('results-cards', 'masonry-grid');
1093        const entry = {
1094            wrapper: sec.wrapper,
1095            body: sec.body,
1096            countSpan: sec.wrapper.querySelector('.sec-live-count'),
1097            count: 0
1098        };
1099        incrementalSections[kind] = entry;
1100
1101        const container = document.getElementById('results-cards');
1102        if (kind === 'multi' && incrementalSections.single) {
1103            // Multi always comes first
1104            container.insertBefore(sec.wrapper, incrementalSections.single.wrapper);
1105        } else {
1106            container.appendChild(sec.wrapper);
1107        }
1108        return entry;
1109    }
1110
1111    // Insert a single-profile card into the grid, alphabetically by site name.
1112    function insertSingleCardSorted(sectionBody, siteName, result) {
1113        const tmp = document.createElement('div');
1114        addSingleProfileSite(tmp, siteName, result);
1115        const card = tmp.firstElementChild;
1116        const key = siteName.toLowerCase();
1117        let ref = null;
1118        sectionBody.querySelectorAll(':scope > .profile-card').forEach(c => {
1119            if (ref === null && key < (c.dataset.site || '').toLowerCase()) ref = c;
1120        });
1121        sectionBody.insertBefore(card, ref);
1122    }
1123
1124    // Insert a whole multi-profile group (header + cards), alphabetically by site name.
1125    function insertMultiGroupSorted(sectionBody, siteName, result) {
1126        const tmp = document.createElement('div');
1127        addMultiProfileSite(tmp, siteName, result);
1128        const nodes = Array.from(tmp.childNodes);
1129        const key = siteName.toLowerCase();
1130        let ref = null;
1131        sectionBody.querySelectorAll('.results-site-header[data-site]').forEach(h => {
1132            if (ref === null && key < (h.dataset.site || '').toLowerCase()) ref = h;
1133        });
1134        nodes.forEach(n => sectionBody.insertBefore(n, ref));
1135    }
1136
1137    // Render a single found site as it arrives (deduped, sorted in place).
1138    function renderSiteResult(siteName, result) {
1139        if (!result || !result.exists) return;
1140        if (renderedSites.has(siteName)) return;
1141        renderedSites.add(siteName);
1142        searchResults[siteName] = result;
1143
1144        const kind = (result.profiles && result.profiles.length > 1) ? 'multi' : 'single';
1145        const sec = getOrCreateSection(kind);
1146        if (kind === 'multi') {
1147            insertMultiGroupSorted(sec.body, siteName, result);
1148        } else {
1149            insertSingleCardSorted(sec.body, siteName, result);
1150        }
1151        sec.count++;
1152        if (sec.countSpan) sec.countSpan.textContent = sec.count;
1153
1154        resultsCount = renderedSites.size;
1155        document.getElementById('results-container').style.display = 'block';
1156        document.getElementById('no-results').style.display = 'none';
1157        showSelectionBar();
1158        scheduleMasonry();
1159    }
1160
1161    // Finalize the results view. Found sites are usually already rendered live;
1162    // this fills in any missed ones (fallback) plus the blocked / not-found sections.
1163    function processResults(data) {
1164        const resultsData = data.search_results || data;
1165        const blockedSites = data.blocked_sites || [];
1166        const notFoundSites = data.not_found_sites || [];
1167        lastBlockedSites = blockedSites;
1168        lastNotFoundSites = notFoundSites;
1169        const container = document.getElementById('results-cards');
1170
1171        // Fallback: render any found site that wasn't pushed live (reconnection, test load…)
1172        for (const [siteName, result] of Object.entries(resultsData)) {
1173            if (result && result.exists && !renderedSites.has(siteName)) {
1174                renderSiteResult(siteName, result);
1175            }
1176        }
1177
1178        resultsCount = renderedSites.size;
1179
1180        if (resultsCount > 0) {
1181            document.getElementById('results-container').style.display = 'block';
1182            showSelectionBar();
1183
1184            // Add blocked sites section
1185            if (blockedSites.length > 0) {
1186                addSpacer(container, 'small');
1187                const sec = createCollapsibleSection(`<i class="iconoir-shield-alert" style="color:var(--error);"></i> BLOCKED SITES (${blockedSites.length})`, false);
1188                addBlockedSitesContent(sec.body, blockedSites);
1189                container.appendChild(sec.wrapper);
1190            }
1191
1192            // Add not found sites section
1193            if (notFoundSites.length > 0) {
1194                addSpacer(container, 'small');
1195                const sec = createCollapsibleSection(`<i class="iconoir-xmark-circle" style="color:var(--text-muted);"></i> NOT FOUND (${notFoundSites.length})`, false);
1196                addNotFoundSitesContent(sec.body, notFoundSites);
1197                container.appendChild(sec.wrapper);
1198            }
1199        } else {
1200            document.getElementById('no-results').style.display = 'block';
1201        }
1202
1203        summaryReady = true;
1204        buildSearchSummary();
1205        scheduleMasonry();
1206    }
1207
1208    // ===== CROSS-SITE SUMMARY =====
1209    // Aggregates the identity signals scattered across every found profile so
1210    // the analyst sees the pivots (real names, username variants, countries,
1211    // emails, declared identities) without opening 30 cards one by one.
1212
1213    const SUMMARY_KEYS = {
1214        names:     ['fullname', 'full_name', 'fullName', 'realname', 'display_name', 'name', 'profile_title', 'author', 'artist'],
1215        handles:   ['username', 'handle', 'login', 'screenname', 'requested_username', 'pseuds'],
1216        countries: ['country', 'country_code'],
1217        places:    ['location', 'city', 'state', 'region', 'business_address'],
1218        socials:   ['twitter', 'twitter_username', 'instagram', 'facebook', 'linkedin', 'tiktok',
1219                    'youtube', 'bluesky', 'threads', 'github_username', 'website', 'website_url', 'publication'],
1220        dates:     ['date_creation', 'member_since', 'member_since_year', 'joined', 'joined_at', 'join_date',
1221                    'created', 'created_at', 'created_on', 'created_date', 'creation_date', 'registration',
1222                    'registration_date', 'registered', 'registered_at', 'regdate', 'signup_year',
1223                    'mapper_since', 'scrobbling_since', 'first_uploaded_on']
1224    };
1225
1226    const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]{2,}/g;
1227    const SUMMARY_CHIP_LIMIT = 12;
1228
1229    let summaryReady = false;          // no panel until the search has finished
1230    let expandedFacets = new Set();    // survives the rebuilds triggered by selection changes
1231
1232    let countryNamer = null;
1233    try { countryNamer = new Intl.DisplayNames(['en'], { type: 'region' }); } catch (e) { /* older browser */ }
1234
1235    function normalizeCountry(value) {
1236        const raw = String(value).trim();
1237        if (countryNamer && /^[A-Za-z]{2}$/.test(raw)) {
1238            try {
1239                const full = countryNamer.of(raw.toUpperCase());
1240                if (full && full.toUpperCase() !== raw.toUpperCase()) return full;
1241            } catch (e) { /* not a region code */ }
1242        }
1243        return raw;
1244    }
1245
1246    // A 4-digit year anywhere in the value; good enough across the many date
1247    // formats sites return, and we only ever compare years.
1248    function extractYear(value) {
1249        const m = String(value).match(/\b(19[89]\d|20[0-4]\d)\b/);
1250        if (!m) return null;
1251        const year = parseInt(m[1], 10);
1252        return year <= new Date().getFullYear() ? year : null;
1253    }
1254
1255    // La liste exacte des noms de pays en anglais, dérivée des codes ISO-3166
1256    // que le navigateur connaît déjà. Évite d'embarquer une table de 250 lignes.
1257    let countryNameSet = null;
1258    function knownCountryNames() {
1259        if (countryNameSet) return countryNameSet;
1260        countryNameSet = new Set();
1261        if (!countryNamer) return countryNameSet;
1262        const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
1263        for (const a of A) for (const b of A) {
1264            try {
1265                const name = countryNamer.of(a + b);
1266                if (name && name !== a + b) countryNameSet.add(name.toLowerCase());
1267            } catch (e) { /* pas un code région */ }
1268        }
1269        return countryNameSet;
1270    }
1271
1272    // Vrai si `short` apparaît dans `long` sur des frontières de mot :
1273    // « lyon » dans « lyon, france » oui, « ana » dans « canada » non.
1274    function containsToken(short, long) {
1275        const esc = short.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1276        return new RegExp('(^|[^a-z0-9])' + esc + '([^a-z0-9]|$)').test(long);
1277    }
1278
1279    // « Lyon » et « Lyon, France » sont la même trace : on garde la plus
1280    // précise et on lui recolle les sites de la plus vague.
1281    function mergeContained(bucket) {
1282        const entries = Array.from(bucket.entries()).sort((a, b) => b[0].length - a[0].length);
1283        entries.forEach(([longKey, longEntry], i) => {
1284            if (!bucket.has(longKey)) return;
1285            entries.slice(i + 1).forEach(([shortKey, shortEntry]) => {
1286                if (!bucket.has(shortKey) || shortKey === longKey) return;
1287                if (containsToken(shortKey, longKey)) {
1288                    shortEntry.sites.forEach(site => longEntry.sites.add(site));
1289                    bucket.delete(shortKey);
1290                }
1291            });
1292        });
1293    }
1294
1295    function summaryAdd(bucket, value, site) {
1296        if (value === null || value === undefined) return;
1297        const label = String(value).trim().replace(/\s+/g, ' ');
1298        if (!label || label.length > 70) return;
1299        if (!/[A-Za-z0-9]/.test(label)) return;
1300        const key = label.toLowerCase();
1301        let entry = bucket.get(key);
1302        if (!entry) { entry = { label: label, sites: new Set() }; bucket.set(key, entry); }
1303        entry.sites.add(site);
1304    }
1305
1306    function collectSummaryData() {
1307        const acc = {
1308            names: new Map(), handles: new Map(), countries: new Map(),
1309            places: new Map(), emails: new Map(), socials: new Map()
1310        };
1311        const echo = String(currentUsername || '').trim().toLowerCase();
1312        let oldest = null;
1313        let siteCount = 0;
1314        let profileCount = 0;
1315
1316        // Same source as the exports: deselecting a card must drop its data
1317        // from the summary too, otherwise the panel contradicts what you keep.
1318        for (const [site, result] of Object.entries(getSelectedExportData())) {
1319            if (!result || !result.exists) continue;
1320            siteCount++;
1321            const profiles = (result.profiles && result.profiles.length) ? result.profiles : [result];
1322
1323            profiles.forEach(profile => {
1324                if (!profile || typeof profile !== 'object') return;
1325                profileCount++;
1326
1327                SUMMARY_KEYS.names.forEach(k => summaryAdd(acc.names, profile[k], site));
1328                const first = profile.first_name || profile.firstname || profile.givenName || '';
1329                const last = profile.last_name || profile.lastname || profile.familyName || '';
1330                if (first || last) summaryAdd(acc.names, `${first} ${last}`, site);
1331
1332                SUMMARY_KEYS.handles.forEach(k => summaryAdd(acc.handles, profile[k], site));
1333                SUMMARY_KEYS.places.forEach(k => summaryAdd(acc.places, profile[k], site));
1334                SUMMARY_KEYS.countries.forEach(k => {
1335                    if (profile[k]) summaryAdd(acc.countries, normalizeCountry(profile[k]), site);
1336                });
1337                SUMMARY_KEYS.socials.forEach(k => summaryAdd(acc.socials, profile[k], site));
1338                (profile.rel_me_links || []).forEach(url => summaryAdd(acc.socials, url, site));
1339                (result.rel_me_links || []).forEach(url => summaryAdd(acc.socials, url, site));
1340
1341                SUMMARY_KEYS.dates.forEach(k => {
1342                    if (!profile[k]) return;
1343                    const year = extractYear(profile[k]);
1344                    if (year && (!oldest || year < oldest.year)) oldest = { year: year, site: site };
1345                });
1346
1347                // Emails hide in bios as often as in dedicated fields, so sweep
1348                // every string value rather than a fixed key list.
1349                for (const [key, value] of Object.entries(profile)) {
1350                    if (typeof value !== 'string' || key.endsWith('_placeholder_id')) continue;
1351                    const found = value.match(EMAIL_RE);
1352                    if (found) found.forEach(mail => summaryAdd(acc.emails, mail.toLowerCase(), site));
1353                }
1354            });
1355        }
1356
1357        // The searched username is already in the results title; repeating it as
1358        // a "discovery" on 30 sites buries the actual variants.
1359        [acc.names, acc.handles].forEach(bucket => bucket.delete(echo));
1360
1361        // Un pseudo déjà listé en variante n'est pas un nom : beaucoup de sites
1362        // recopient le handle dans display_name, ce qui polluait « Names ».
1363        acc.handles.forEach((entry, key) => acc.names.delete(key));
1364
1365        // Un champ « location » qui vaut « France » est un pays, pas un lieu.
1366        const countries = knownCountryNames();
1367        Array.from(acc.places.entries()).forEach(([key, entry]) => {
1368            if (!countries.has(key)) return;
1369            acc.places.delete(key);
1370            const existing = acc.countries.get(key);
1371            if (existing) entry.sites.forEach(site => existing.sites.add(site));
1372            else acc.countries.set(key, entry);
1373        });
1374        mergeContained(acc.places);
1375
1376        return { acc, oldest, siteCount, profileCount };
1377    }
1378
1379    function summaryChipsHtml(bucket, opts) {
1380        const options = opts || {};
1381        const entries = Array.from(bucket.values()).sort((a, b) => {
1382            const diff = b.sites.size - a.sites.size;
1383            return diff !== 0 ? diff : a.label.localeCompare(b.label);
1384        });
1385
1386        const chips = entries.map((entry, i) => {
1387            const overflow = i >= SUMMARY_CHIP_LIMIT ? ' is-overflow' : '';
1388            const accent = options.accent ? ' summary-chip-accent' : '';
1389            const sites = Array.from(entry.sites).sort().join(', ');
1390            const count = entry.sites.size > 1 ? `<span class="chip-n">${entry.sites.size}</span>` : '';
1391            const isUrl = /^https?:\/\//.test(entry.label);
1392            const text = isUrl ? entry.label.replace(/^https?:\/\//, '').replace(/\/$/, '') : entry.label;
1393            const tip = escAttr(entry.label) + '&#10;Found on: ' + escAttr(sites);
1394            const inner = isUrl
1395                ? `<a href="${escAttr(entry.label)}" target="_blank" rel="noopener noreferrer" class="chip-text">${escapeHtml(text)}</a>`
1396                : `<span class="chip-text">${escapeHtml(text)}</span>`;
1397            const focusable = isUrl ? '' : ' tabindex="0" role="button"';
1398            return `<span class="summary-chip${accent}${overflow}"${focusable} data-copy="${escAttr(entry.label)}" title="${tip}">${inner}${count}</span>`;
1399        });
1400
1401        const hidden = entries.length - SUMMARY_CHIP_LIMIT;
1402        if (hidden > 0) {
1403            chips.push(`<span class="summary-chip summary-chip-more" tabindex="0" role="button" data-expand="1">+${hidden} more</span>`);
1404        }
1405        return chips.join('');
1406    }
1407
1408    function summaryFacetHtml(label, bucket, opts) {
1409        if (!bucket.size) return '';
1410        const expanded = expandedFacets.has(label) ? ' expanded' : '';
1411        return `<div class="summary-facet${expanded}" data-facet="${escAttr(label)}">
1412            <div class="summary-facet-label">${label} <span class="facet-count">${bucket.size}</span></div>
1413            <div class="summary-chips">${summaryChipsHtml(bucket, opts)}</div>
1414        </div>`;
1415    }
1416
1417    function buildSearchSummary() {
1418        const panel = document.getElementById('search-summary');
1419        if (!panel || !summaryReady) return;
1420
1421        const { acc, oldest, siteCount, profileCount } = collectSummaryData();
1422        const facets = [
1423            summaryFacetHtml('Names', acc.names, { accent: true }),
1424            summaryFacetHtml('Username variants', acc.handles),
1425            summaryFacetHtml('Countries', acc.countries),
1426            summaryFacetHtml('Locations', acc.places),
1427            summaryFacetHtml('Emails', acc.emails, { accent: true }),
1428            summaryFacetHtml('Declared identities', acc.socials)
1429        ].filter(Boolean).join('');
1430
1431        if (!facets) {
1432            panel.style.display = 'none';
1433            panel.innerHTML = '';
1434            return;
1435        }
1436
1437        const profilePart = profileCount !== siteCount ? ` · ${profileCount} profiles` : '';
1438        const oldestPart = oldest ? ` · oldest trace ${oldest.year} (${escapeHtml(oldest.site)})` : '';
1439        panel.innerHTML = `
1440            <div class="search-summary-header">
1441                <span><i class="iconoir-fingerprint"></i> Identity summary</span>
1442                <span class="summary-sub">${siteCount} sites${profilePart}${oldestPart}</span>
1443            </div>
1444            <div class="search-summary-grid">${facets}</div>`;
1445        panel.style.display = 'block';
1446    }
1447
1448    document.addEventListener('keydown', function(e) {
1449        if (e.key !== 'Enter' && e.key !== ' ') return;
1450        const chip = e.target.closest('.summary-chip');
1451        // Sur un chip-lien, Entrée doit ouvrir le lien : preventDefault ici
1452        // l'aurait remplacé par une copie.
1453        if (!chip || e.target.closest('a')) return;
1454        e.preventDefault();
1455        chip.click();
1456    });
1457
1458    document.addEventListener('click', function(e) {
1459        const more = e.target.closest('.summary-chip-more');
1460        if (more) {
1461            const facet = more.closest('.summary-facet');
1462            facet.classList.add('expanded');
1463            expandedFacets.add(facet.dataset.facet);
1464            return;
1465        }
1466        const chip = e.target.closest('.summary-chip');
1467        if (!chip || e.target.closest('a') || !navigator.clipboard) return;
1468        navigator.clipboard.writeText(chip.dataset.copy || '').then(() => showToast('Copied'));
1469    });
1470
1471    function addSectionHeader(container, title) {
1472        const header = document.createElement('h1');
1473        header.className = 'results-section-header';
1474        header.textContent = title;
1475        container.appendChild(header);
1476        addSpacer(container, 'small');
1477    }
1478
1479    // Wrap content in a collapsible accordion section.
1480    // Returns {wrapper, body} — append children to body.
1481    function createCollapsibleSection(title, openByDefault = true) {
1482        const wrapper = document.createElement('div');
1483        wrapper.className = 'section-accordion' + (openByDefault ? ' open' : '');
1484
1485        const header = document.createElement('div');
1486        header.className = 'section-accordion-header';
1487        header.setAttribute('role', 'button');
1488        header.setAttribute('tabindex', '0');
1489        header.setAttribute('aria-expanded', String(openByDefault));
1490        // Un seul élément flex pour le titre : sinon les bouts de texte nus
1491        // autour du compteur deviennent des items anonymes et récoltent le gap,
1492        // ce qui donnait « SINGLE PROFILE SITES ( 13 ) ».
1493        header.innerHTML = `<span class="sa-chevron"></span><span class="sa-title">${title}</span>`;
1494        const toggle = () => {
1495            const open = wrapper.classList.toggle('open');
1496            header.setAttribute('aria-expanded', String(open));
1497            if (open) scheduleMasonry();
1498        };
1499        header.onclick = toggle;
1500        header.onkeydown = (e) => {
1501            if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
1502        };
1503        wrapper.appendChild(header);
1504
1505        const bodyOuter = document.createElement('div');
1506        bodyOuter.className = 'section-accordion-body';
1507        const bodyInner = document.createElement('div');
1508        bodyInner.className = 'section-accordion-body-inner';
1509        bodyOuter.appendChild(bodyInner);
1510        wrapper.appendChild(bodyOuter);
1511
1512        return { wrapper, body: bodyInner };
1513    }
1514
1515    function addSpacer(container, size) {
1516        const spacer = document.createElement('div');
1517        spacer.className = 'results-spacer-' + size;
1518        container.appendChild(spacer);
1519    }
1520
1521    // Multi-profile site (like Bluesky with multiple accounts) - IDENTICAL to index.html
1522    function addMultiProfileSite(container, siteName, result) {
1523        // Site header
1524        const siteHeader = document.createElement('h2');
1525        siteHeader.className = 'results-site-header';
1526        siteHeader.textContent = siteName;
1527        siteHeader.dataset.site = siteName;
1528        container.appendChild(siteHeader);
1529
1530        const profileCount = document.createElement('span');
1531        profileCount.className = 'results-site-count';
1532        profileCount.textContent = `(${result.profiles.length} profiles found)`;
1533        container.appendChild(profileCount);
1534
1535        // Add spacing after header
1536        const spacerAfter = document.createElement('div');
1537        spacerAfter.className = 'results-spacer-small';
1538        container.appendChild(spacerAfter);
1539
1540        // Add each profile
1541        result.profiles.forEach((profile, profileIndex) => {
1542            const card = document.createElement('div');
1543            card.className = 'profile-card';
1544            card.dataset.site = siteName;
1545
1546            // Build profile info
1547            const displayName = profile.display_name || profile.username || profile.name || '';
1548            const handle = profile.handle ? `@${profile.handle}` : '';
1549            const profileUrl = profile.profile_url || profile.url || '#';
1550            const avatar = profile.avatar || '/static/img/default-avatar.svg';
1551            const bio = profile.bio || profile.description || '';
1552
1553            // Build stats - separate URLs from data
1554            let dataStats = '';
1555            let urlStats = '';
1556            for (const [key, value] of Object.entries(profile)) {
1557                if (key !== 'avatar' && key !== 'profile_url' && key !== 'url' && key !== 'handle' &&
1558                    key !== 'display_name' && key !== 'username' && key !== 'name' && key !== 'bio' &&
1559                    key !== 'description' && value !== '' && value !== null && value !== undefined &&
1560                    typeof value !== 'object') {
1561
1562                    let displayValue = formatStatValue(value);
1563                    let displayLabel = key.replace(/_/g, ' ');
1564                    if (key === 'country_flag' && value) {
1565                        displayValue = `<img src="${escAttr(value)}" class="profile-image-small" alt="Flag">`;
1566                    }
1567
1568                    const strVal = String(value);
1569                    const isUrl = strVal.startsWith('http://') || strVal.startsWith('https://');
1570
1571                    if (isUrl) {
1572                        const shortUrl = strVal.replace(/^https?:\/\//, '').split('?')[0];
1573                        const truncated = shortUrl.length > 35 ? shortUrl.substring(0, 32) + '...' : shortUrl;
1574                        urlStats += `<div class="profile-stat">
1575                            <div class="profile-stat-value"><a href="${escAttr(strVal)}" target="_blank" class="copyable" data-full="${escAttr(strVal)}">${truncated}</a></div>
1576                            <div class="profile-stat-label">${displayLabel}</div>
1577                        </div>`;
1578                    } else {
1579                        dataStats += `<div class="profile-stat">
1580                            <div class="profile-stat-value copyable" data-full="${escAttr(strVal)}">${displayValue}</div>
1581                            <div class="profile-stat-label">${displayLabel}</div>
1582                        </div>`;
1583                    }
1584                }
1585            }
1586
1587            const allStats = dataStats + urlStats;
1588            const shortUrl = profileUrl.replace(/^https?:\/\//, '').split('?')[0];
1589            const truncatedUrl = shortUrl.length > 45 ? shortUrl.substring(0, 42) + '...' : shortUrl;
1590
1591            const isDefaultAvatar = !profile.avatar;
1592            card.innerHTML = `
1593                <input type="checkbox" class="card-select" checked data-site="${escAttr(siteName)}" data-profile-index="${profileIndex}" onchange="updateSelectionCount()" aria-label="Include ${escAttr(siteName)} / ${escAttr(displayName)} in exports">
1594                <div class="profile-info">
1595                    ${isDefaultAvatar ? `<img class="profile-avatar" src="/static/img/default-avatar.svg">` : `
1596                    <div class="profile-avatar-wrap" data-avatar="${escAttr(avatar)}">
1597                        <img class="profile-avatar" src="${escAttr(avatar)}" onerror="this.src='/static/img/default-avatar.svg'">
1598                    </div>`}
1599                    <div>
1600                        <div class="profile-site">${escAttr(siteName)}</div>
1601                        ${displayName ? `<div class="profile-name">${escAttr(displayName)}</div>` : ''}
1602                        ${handle ? `<div class="profile-handle">${escAttr(handle)}</div>` : ''}
1603                    </div>
1604                </div>
1605                ${bio ? `<div class="profile-bio">${escAttr(bio)}</div>` : ''}
1606                ${allStats ? `<div class="profile-stats">${allStats}</div>` : ''}
1607                ${buildRelMeHtml(profile)}
1607
1608                <div class="profile-actions">
1609                    <a class="profile-url copyable" href="${escAttr(profileUrl)}" target="_blank" data-full="${escAttr(profileUrl)}">${truncatedUrl}</a>
1610                </div>
1611            `;
1612
1613            container.appendChild(card);
1614        });
1615
1616        // Add spacing after the site's profiles
1617        const spacer = document.createElement('div');
1618        spacer.className = 'results-spacer-medium';
1619        container.appendChild(spacer);
1620    }
1621
1622    // Single-profile site - IDENTICAL to index.html
1623    function addSingleProfileSite(container, siteName, result) {
1624        const card = document.createElement('div');
1625        card.className = 'profile-card';
1626        card.dataset.site = siteName;
1627
1628        // Get profile data (either from profiles[0] or directly from result)
1629        const profile = (result.profiles && result.profiles.length > 0) ? result.profiles[0] : result;
1630
1631        // Build profile info
1632        const composedName = [profile.first_name || profile.firstname || profile.givenName,
1633                              profile.last_name || profile.lastname || profile.familyName]
1634                             .filter(Boolean).join(' ').trim();
1635        const displayName = profile.fullname || profile.full_name || profile.display_name || composedName ||
1636                            profile.realname || profile.username || profile.name || profile.title || '';
1637        const handle = profile.handle ? `@${profile.handle}` : '';
1638        const profileUrl = profile.profile_url || profile.url || result.url || '#';
1639        const avatar = profile.avatar || result.avatar || '/static/img/default-avatar.svg';
1640        const bio = profile.bio || profile.description || '';
1641
1642        // Build stats using CSS classes
1643        let statsHtml = '';
1644        const excludeKeys = ['exists', 'url', 'profile_url', 'profiles', 'avatar', 'avatar_url', 'profile_image',
1645            'fullname', 'full_name', 'display_name', 'username', 'name', 'handle', 'title', 'bio', 'description',
1646            'summary', 'site_name', 'image', 'locale', 'author', 'canonical_url', 'rel_me_links',
1647            'pgp_key', 'pgp_key_raw', 'pgp_key_full', 'fingerprint', 'pgp_email_placeholder_id'];
1648
1649        // Separate URL fields from data fields
1650        let dataStats = '';
1651        let urlStats = '';
1652        for (const [key, value] of Object.entries(profile)) {
1653            if (!excludeKeys.includes(key) && !key.endsWith('_full') && value !== '' && value !== null && value !== undefined &&
1654                typeof value !== 'object' && !Array.isArray(value)) {
1655
1656                let displayValue = formatStatValue(value);
1657                let displayLabel = key.replace(/_/g, ' ');
1658
1659                if (key === 'country_flag' && value) {
1660                    displayValue = `<img src="${escAttr(value)}" class="profile-image-small" alt="Flag">`;
1661                } else if (key === 'pgp_email') {
1662                    displayLabel = 'email (PGP)';
1663                }
1664
1665                const strVal = String(value);
1666                const isUrl = strVal.startsWith('http://') || strVal.startsWith('https://');
1667
1668                if (isUrl) {
1669                    const shortUrl = strVal.replace(/^https?:\/\//, '').split('?')[0];
1670                    const truncated = shortUrl.length > 35 ? shortUrl.substring(0, 32) + '...' : shortUrl;
1671                    urlStats += `<div class="profile-stat">
1672                        <div class="profile-stat-value"><a href="${escAttr(strVal)}" target="_blank" class="copyable" data-full="${escAttr(strVal)}">${truncated}</a></div>
1673                        <div class="profile-stat-label">${displayLabel}</div>
1674                    </div>`;
1675                } else {
1676                    dataStats += `<div class="profile-stat">
1677                        <div class="profile-stat-value copyable" data-full="${escAttr(strVal)}">${displayValue}</div>
1678                        <div class="profile-stat-label">${displayLabel}</div>
1679                    </div>`;
1680                }
1681            }
1682        }
1683
1684        const allStats = dataStats + urlStats;
1685        const shortUrl = profileUrl.replace(/^https?:\/\//, '').split('?')[0];
1686        const truncatedUrl = shortUrl.length > 45 ? shortUrl.substring(0, 42) + '...' : shortUrl;
1687
1688        const isDefaultAvatar = !profile.avatar && !result.avatar;
1689        card.innerHTML = `
1690            <input type="checkbox" class="card-select" checked data-site="${escAttr(siteName)}" onchange="updateSelectionCount()" aria-label="Include ${escAttr(siteName)} in exports">
1691            <div class="profile-info">
1692                ${isDefaultAvatar ? `<img class="profile-avatar" src="/static/img/default-avatar.svg">` : `
1693                <div class="profile-avatar-wrap" data-avatar="${escAttr(avatar)}">
1694                    <img class="profile-avatar" src="${escAttr(avatar)}" onerror="this.src='/static/img/default-avatar.svg'">
1695                </div>`}
1696                <div>
1697                    <div class="profile-site">${escAttr(siteName)}</div>
1698                    ${displayName ? `<div class="profile-name">${escAttr(displayName)}</div>` : ''}
1699                    ${handle ? `<div class="profile-handle">${escAttr(handle)}</div>` : ''}
1700                </div>
1701            </div>
1702            ${bio ? `<div class="profile-bio">${escAttr(bio)}</div>` : ''}
1703            ${allStats ? `<div class="profile-stats">${allStats}</div>` : ''}
1704            ${buildRelMeHtml(profile, result)}
1705            <div class="profile-actions">
1706                <a class="profile-url copyable" href="${escAttr(profileUrl)}" target="_blank" data-full="${escAttr(profileUrl)}">${truncatedUrl}</a>
1707            </div>
1708        `;
1709
1710        container.appendChild(card);
1711    }
1712
1713    function addBlockedSitesContent(body, blockedSites) {
1714        const grid = document.createElement('div');
1715        grid.className = 'results-cards results-cards-flush masonry-grid';
1716
1717        blockedSites.forEach(site => {
1718            const card = document.createElement('div');
1719            card.className = 'profile-card blocked-site-card';
1720            card.innerHTML = `
1721                <div class="profile-info">
1722                    <div>
1723                        <div class="profile-name">${escapeHtml(site.site_name)}</div>
1724                        <div class="profile-handle">
1725                            <span class="blocked-status">HTTP ${site.status_code}</span>
1726                        </div>
1727                    </div>
1728                </div>
1729                <div class="profile-bio">${escapeHtml(site.reason)}</div>
1730                <div class="profile-actions">
1731                    <a class="profile-url" href="${escAttr(site.display_url)}" target="_blank">Verify Manually</a>
1732                </div>
1733            `;
1734            grid.appendChild(card);
1735        });
1736        body.appendChild(grid);
1737    }
1738
1739    function addNotFoundSitesContent(body, notFoundSites) {
1740        const grid = document.createElement('div');
1741        grid.className = 'results-cards results-cards-flush masonry-grid';
1742
1743        notFoundSites.forEach(site => {
1744            const card = document.createElement('div');
1745            card.className = 'profile-card not-found-site-card';
1746            card.innerHTML = `
1747                <div class="profile-info">
1748                    <div>
1749                        <div class="profile-name">${escapeHtml(site.site_name)}</div>
1750                        <div class="profile-handle">
1751                            <span class="not-found-status">HTTP ${site.status_code}</span>
1752                        </div>
1753                    </div>
1754                </div>
1755                <div class="profile-actions">
1756                    <a class="profile-url" href="${escAttr(site.display_url)}" target="_blank">Verify Manually</a>
1757                </div>
1758            `;
1759            grid.appendChild(card);
1760        });
1761        body.appendChild(grid);
1762    }
1763
1764    function searchError(error) {
1765        isSearching = false;
1766        hideProgress();
1767        document.getElementById('search-btn').disabled = false;
1768        appModal('Search error: ' + error, 'error');
1769    }
1770
1771    function escapeHtml(text) {
1772        if (!text) return '';
1773        const div = document.createElement('div');
1774        div.textContent = text;
1775        return div.innerHTML;
1776    }
1777
1778    function showToast(message) {
1779        const toast = document.createElement('div');
1780        toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);color:var(--text-primary);padding:8px 16px;border-radius:var(--radius-full);font-size:var(--font-size-sm);z-index:10000;border:1px solid var(--border-color);';
1781        toast.textContent = message;
1782        document.body.appendChild(toast);
1783        setTimeout(() => toast.remove(), 1500);
1784    }
1785
1786    // Avatar functions
1787    function copyAvatarUrl(url) {
1788        navigator.clipboard.writeText(url).then(() => showToast('Avatar URL copied'));
1789    }
1790
1791    function downloadAvatar(url) {
1792        const a = document.createElement('a');
1793        a.href = url;
1794        a.download = 'avatar-' + url.split('/').pop().split('?')[0];
1795        a.target = '_blank';
1796        a.rel = 'noopener';
1797        document.body.appendChild(a);
1798        a.click();
1799        a.remove();
1800    }
1801
1802    // --- Avatar hover popover (portaled to <body>) -------------------------
1803    // Cards live inside accordions with overflow:hidden, so an absolutely
1804    // positioned popover gets clipped by the section frame. A single shared
1805    // element in <body>, positioned with fixed coordinates, escapes that.
1806    let avatarPopoverEl = null;
1807    let avatarPopoverWrap = null;
1808
1809    function getAvatarPopover() {
1810        if (avatarPopoverEl) return avatarPopoverEl;
1811        avatarPopoverEl = document.createElement('div');
1812        avatarPopoverEl.className = 'avatar-popover';
1813        avatarPopoverEl.innerHTML = `
1814            <img alt="Avatar" onerror="this.src='/static/img/default-avatar.svg'">
1815            <div class="avatar-popover-actions">
1816                <button class="avatar-action-btn" data-action="copy" title="Copy URL"><i class="iconoir-copy"></i> Copy</button>
1817                <button class="avatar-action-btn" data-action="save" title="Download"><i class="iconoir-download"></i> Save</button>
1818            </div>`;
1819        avatarPopoverEl.addEventListener('click', (e) => {
1820            const btn = e.target.closest('.avatar-action-btn');
1821            if (!btn || !avatarPopoverWrap) return;
1822            const url = avatarPopoverWrap.dataset.avatar;
1823            if (btn.dataset.action === 'copy') copyAvatarUrl(url);
1824            else downloadAvatar(url);
1825        });
1826        document.body.appendChild(avatarPopoverEl);
1827        return avatarPopoverEl;
1828    }
1829
1830    function showAvatarPopover(wrap) {
1831        const url = wrap.dataset.avatar;
1832        if (!url) return;
1833        const pop = getAvatarPopover();
1834        avatarPopoverWrap = wrap;
1835        pop.querySelector('img').src = url;
1836        pop.classList.add('visible');
1837        positionAvatarPopover();
1838    }
1839
1840    function positionAvatarPopover() {
1841        if (!avatarPopoverEl || !avatarPopoverWrap) return;
1842        const pop = avatarPopoverEl;
1843        const r = avatarPopoverWrap.getBoundingClientRect();
1844        const pw = pop.offsetWidth;
1845        const ph = pop.offsetHeight;
1846        const margin = 8;
1847
1848        let left = r.left + r.width / 2 - pw / 2;
1849        left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
1850
1851        // Prefer above the avatar; flip below when there isn't room.
1852        let top = r.top - ph;
1853        const flip = top < margin;
1854        if (flip) top = Math.min(r.bottom, window.innerHeight - ph - margin);
1855
1856        pop.classList.toggle('flip-below', flip);
1857        pop.style.left = Math.round(left) + 'px';
1858        pop.style.top = Math.round(top) + 'px';
1859    }
1860
1861    function hideAvatarPopover() {
1862        if (!avatarPopoverEl) return;
1863        avatarPopoverEl.classList.remove('visible');
1864        avatarPopoverWrap = null;
1865    }
1866
1867    document.addEventListener('mouseover', (e) => {
1868        const wrap = e.target.closest('.profile-avatar-wrap');
1869        if (wrap) {
1870            if (wrap !== avatarPopoverWrap) showAvatarPopover(wrap);
1871            return;
1872        }
1873        if (avatarPopoverEl && e.target.closest('.avatar-popover')) return;
1874        hideAvatarPopover();
1875    });
1876
1877    window.addEventListener('scroll', hideAvatarPopover, true);
1878    window.addEventListener('resize', hideAvatarPopover);
1879
1880    // Selection functions
1881    function toggleSelectAll(checked) {
1882        document.querySelectorAll('.card-select').forEach(cb => {
1883            cb.checked = checked;
1884            cb.closest('.profile-card').classList.toggle('card-deselected', !checked);
1885        });
1886        updateSelectionCount();
1887    }
1888
1889    function updateSelectionCount() {
1890        const all = document.querySelectorAll('.card-select');
1891        const checked = document.querySelectorAll('.card-select:checked');
1892        const countEl = document.getElementById('selection-count');
1893        const selectAllCb = document.getElementById('select-all-cb');
1894
1895        if (countEl) countEl.textContent = `${checked.length} / ${all.length} selected`;
1896        if (selectAllCb) selectAllCb.checked = checked.length === all.length;
1897
1898        // Visual feedback
1899        all.forEach(cb => {
1900            cb.closest('.profile-card').classList.toggle('card-deselected', !cb.checked);
1901        });
1902
1903        buildSearchSummary();
1904    }
1905
1906    function showSelectionBar() {
1907        const bar = document.getElementById('selection-bar');
1908        if (bar) bar.style.display = 'flex';
1909        updateSelectionCount();
1910    }
1911
1912    // Build export data respecting per-profile selection
1913    function getSelectedExportData() {
1914        const exportData = {};
1915        // Collect checked checkboxes grouped by site
1916        const checkedBySite = {};
1917        document.querySelectorAll('.card-select:checked').forEach(cb => {
1918            const site = cb.dataset.site;
1919            if (!checkedBySite[site]) checkedBySite[site] = [];
1920            const idx = cb.dataset.profileIndex;
1921            checkedBySite[site].push(idx !== undefined ? parseInt(idx) : null);
1922        });
1923
1924        for (const [site, indices] of Object.entries(checkedBySite)) {
1925            const result = searchResults[site];
1926            if (!result) continue;
1927
1928            // Multi-profile: filter to selected indices only
1929            if (result.profiles && result.profiles.length >
1929 1 && indices[0] !== null) {
1930                const filtered = { ...result, profiles: indices.map(i => result.profiles[i]).filter(Boolean) };
1931                if (filtered.profiles.length > 0) exportData[site] = filtered;
1932            } else {
1933                exportData[site] = result;
1934            }
1935        }
1936        return exportData;
1937    }
1938
1939    // Export functions
1940    function exportJSON() {
1941        const exportData = getSelectedExportData();
1942        if (Object.keys(exportData).length === 0) {
1943            appModal('No results selected for export.', 'warning');
1944            return;
1945        }
1946
1947        const data = JSON.stringify(exportData, null, 2);
1948        const blob = new Blob([data], { type: 'application/json' });
1949        const url = URL.createObjectURL(blob);
1950        const a = document.createElement('a');
1951        a.href = url;
1952        a.download = `rpuc_results_${currentUsername}_${Date.now()}.json`;
1953        a.click();
1954        URL.revokeObjectURL(url);
1955    }
1956
1957    function exportPDF() {
1958        const exportData = getSelectedExportData();
1959        if (Object.keys(exportData).length === 0) {
1960            appModal('No results selected for export.', 'warning');
1961            return;
1962        }
1963
1964        const { jsPDF } = window.jspdf;
1965        const doc = new jsPDF();
1966
1967        // Title
1968        doc.setFontSize(16);
1969        doc.text('RPUC Search Results', 14, 20);
1970        doc.setFontSize(12);
1971        doc.text(`Username: ${currentUsername}`, 14, 30);
1972        doc.text(`Date: ${new Date().toLocaleString()}`, 14, 36);
1973
1974        // Table data
1975        const tableData = [];
1976        for (const [siteName, result] of Object.entries(exportData)) {
1977            if (result.profiles && result.profiles.length > 0) {
1978                result.profiles.forEach(profile => {
1979                    tableData.push([
1980                        siteName,
1981                        profile.username || profile.handle || currentUsername,
1982                        profile.profile_url || profile.url || result.url || ''
1983                    ]);
1984                });
1985            } else {
1986                tableData.push([
1987                    siteName,
1988                    result.username || result.handle || currentUsername,
1989                    result.profile_url || result.url || ''
1990                ]);
1991            }
1992        }
1993
1994        // Create table
1995        doc.autoTable({
1996            head: [['Site', 'Username', 'Profile URL']],
1997            body: tableData,
1998            startY: 45,
1999            styles: { fontSize: 10 },
2000            headStyles: { fillColor: [192, 192, 192], textColor: [0, 0, 0] }
2001        });
2002
2003        // Save PDF
2004        doc.save(`rpuc_results_${currentUsername}_${Date.now()}.pdf`);
2005    }
2006
2007    async function exportZeroneurone() {
2008        const exportData = getSelectedExportData();
2009        if (Object.keys(exportData).length === 0) {
2010            appModal('No results selected for export.', 'warning');
2011            return;
2012        }
2013        const { data, imageMap } = ZeroneuroneExport.fromUserSearch(currentUsername, exportData);
2014        await ZeroneuroneExport.downloadZip(data, `zeroneurone_${currentUsername}_${Date.now()}.json`, imageMap);
2015    }
2016
2017    // ===== COFFRE LOCAL =====
2018    // Le rendu du panneau, le déverrouillage et l'effacement sont dans
2019    // static/js/vault-ui.js, partagés avec geoloc et rhinoscraper. Ici on ne
2020    // décrit que ce qui est propre à la recherche de pseudos.
2021    VaultUI.mount({
2022        tool: 'username_search',
2023        emptyHint: 'Run a search, then use <strong>Save locally</strong> above the results.',
2024        row: entry => ({
2025            title: entry.title,
2026            meta: [
2027                `${entry.meta.sites || 0} sites`,
2028                entry.meta.blocked ? `${entry.meta.blocked} blocked` : null,
2029                entry.meta.notFound ? `${entry.meta.notFound} not found` : null
2030            ].filter(Boolean).join(' · ')
2031        }),
2032        open: entry => restoreSearch(entry),
2033        // Les cartes mesurées pendant que l'onglet était caché ont une hauteur
2034        // nulle : il faut relancer le calcul au retour.
2035        onTab: name => { if (name === 'main') scheduleMasonry(); }
2036    });
2037
2038    async function saveSearchLocally() {
2039        if (!resultsCount) {
2040            appModal('Run a search before saving it.', 'warning');
2041            return;
2042        }
2043        const btn = document.getElementById('save-local-btn');
2044        if (btn) btn.disabled = true;
2045        try {
2046            await VaultUI.save({
2047                title: currentUsername,
2048                meta: {
2049                    sites: resultsCount,
2050                    blocked: lastBlockedSites.length,
2051                    notFound: lastNotFoundSites.length
2052                },
2053                payload: {
2054                    search_results: searchResults,
2055                    blocked_sites: lastBlockedSites,
2056                    not_found_sites: lastNotFoundSites,
2057                    dorks: lastDorkData
2058                }
2059            });
2060        } finally {
2061            if (btn) btn.disabled = false;
2062        }
2063    }
2064
2065    function restoreSearch(entry) {
2066        const data = entry.payload || {};
2067        currentUsername = entry.title;
2068        searchResults = {};
2069        resultsCount = 0;
2070        renderedSites = new Set();
2071        incrementalSections = { multi: null, single: null };
2072        summaryReady = false;
2073        expandedFacets = new Set();
2074        lastDorkData = data.dorks || null;
2075
2076        document.getElementById('results-cards').innerHTML = '';
2077        document.getElementById('results-container').style.display = 'none';
2078        document.getElementById('no-results').style.display = 'none';
2079        document.getElementById('search-summary').style.display = 'none';
2080        document.getElementById('search-summary').innerHTML = '';
2081        document.getElementById('dork-results-container').style.display = 'none';
2082        document.getElementById('dork-results-container').innerHTML = '';
2083        document.getElementById('username-input').value = entry.title || '';
2084
2085        processResults(data);
2086        document.getElementById('results-title').textContent = `Results for "${entry.title}" (saved locally)`;
2087        document.getElementById('results-timestamp').textContent =
2088            `Saved ${new Date(entry.ts).toLocaleString()} in this browser`;
2089        if (lastDorkData) renderDorkResults(lastDorkData);
2090        window.scrollTo({ top: 0, behavior: 'smooth' });
2091    }
2092
2093    // ===== TEST MODE =====
2094    // Add ?test to URL to show "Load JSON" button
2095    if (window.location.search.includes('test')) {
2096        const testBtn = document.getElementById('load-test-btn');
2097        if (testBtn) testBtn.style.display = 'inline-block';
2098    }
2099
2100    function loadTestJSON() {
2101        document.getElementById('test-json-input').click();
2102    }
2103
2104    document.getElementById('test-json-input').addEventListener('change', function(e) {
2105        const file = e.target.files[0];
2106        if (!file) return;
2107        const reader = new FileReader();
2108        reader.onload = function(ev) {
2109            try {
2110                const data = JSON.parse(ev.target.result);
2111                // Reset UI
2112                searchResults = {};
2113                resultsCount = 0;
2114                renderedSites = new Set();
2115                incrementalSections = { multi: null, single: null };
2116                const container = document.getElementById('results-cards');
2117                container.innerHTML = '';
2118                document.getElementById('results-container').style.display = 'none';
2119                document.getElementById('no-results').style.display = 'none';
2120
2121                currentUsername = file.name.replace(/\.json$/i, '').replace(/^rpuc_results_/, '').split('_')[0] || 'test';
2122                document.getElementById('results-title').textContent = `Results for "${currentUsername}" (test data)`;
2123                document.getElementById('results-timestamp').textContent = `Loaded from ${file.name}`;
2124
2125                processResults({ search_results: data });
2126                hideProgress();
2127            } catch (err) {
2128                appModal('Invalid JSON file: ' + err.message, 'error');
2129            }
2130        };
2131        reader.readAsText(file);
2132        e.target.value = '';
2133    });
2134
2135    // ===== COPY TOOLTIP (hover) =====
2136    (function() {
2137        let activeTooltip = null;
2138        let hideTimer = null;
2139
2140        function closeTooltip() {
2141            if (activeTooltip) {
2142                activeTooltip.remove();
2143                activeTooltip = null;
2144            }
2145        }
2146
2147        function showTooltip(target) {
2148            const fullText = target.dataset.full;
2149            if (!fullText) return;
2150
2151            closeTooltip();
2152            clearTimeout(hideTimer);
2153
2154            const rect = target.getBoundingClientRect();
2155            const tooltip = document.createElement('div');
2156            tooltip.className = 'copy-tooltip';
2157            tooltip.innerHTML = `<span class="copy-tooltip-text">${escAttr(fullText)}</span><span class="copy-tooltip-hint">click to copy</span>`;
2158
2159            document.body.appendChild(tooltip);
2160
2161            let top = rect.bottom + 4;
2162            let left = rect.left;
2163            const ttRect = tooltip.getBoundingClientRect();
2164            if (left + ttRect.width > window.innerWidth - 12) left = window.innerWidth - ttRect.width - 12;
2165            if (top + ttRect.height > window.innerHeight - 12) top = rect.top - ttRect.height - 4;
2166            tooltip.style.top = top + 'px';
2167            tooltip.style.left = Math.max(8, left) + 'px';
2168
2169            tooltip.addEventListener('click', function() {
2170                navigator.clipboard.writeText(fullText).then(() => {
2171                    tooltip.querySelector('.copy-tooltip-hint').textContent = 'copied!';
2172                    setTimeout(closeTooltip, 600);
2173                });
2174            });
2175
2176            // Keep tooltip open while hovering it
2177            tooltip.addEventListener('mouseenter', () => clearTimeout(hideTimer));
2178            tooltip.addEventListener('mouseleave', () => { hideTimer = setTimeout(closeTooltip, 200); });
2179
2180            activeTooltip = tooltip;
2181        }
2182
2183        document.addEventListener('mouseover', function(e) {
2184            const target = e.target.closest('.copyable');
2185            if (!target || !target.dataset.full) return;
2186            showTooltip(target);
2187        });
2188
2189        document.addEventListener('mouseout', function(e) {
2190            const target = e.target.closest('.copyable');
2191            if (!target) return;
2192            hideTimer = setTimeout(closeTooltip, 200);
2193        });
2194
2195        document.addEventListener('keydown', function(e) {
2196            if (e.key === 'Escape') closeTooltip();
2197        });
2198    })();
2199
2200    // ===== DORK ENRICHMENT =====
2201    function fetchDorkResults(taskId) {
2202        fetch('/api/dork-results/' + taskId)
2203            .then(r => r.json())
2204            .then(data => {
2205                if (!data.results || data.results.length === 0) return;
2206                lastDorkData = data;
2207                renderDorkResults(data);
2208            })
2209            .catch(err => console.error('Failed to fetch dork results:', err));
2210    }
2211
2212    function highlightUsername(text, username) {
2213        if (!text || !username) return text;
2214        const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2215        return text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark class="hl">$1</mark>');
2216    }
2217
2218    function renderDorkResults(data) {
2219        const wrapper = document.getElementById('dork-results-container');
2220        wrapper.innerHTML = '';
2221        wrapper.style.display = 'block';
2222
2223        const sec = createCollapsibleSection(
2224            `<i class="iconoir-search-engine"></i> ENRICHMENT (${data.total_results})`,
2225            true
2226        );
2227
2228        const u = currentUsername;
2229        let html = '<div class="dork-list">';
2230        data.results.forEach(r => {
2231            const title = highlightUsername(escAttr(r.title || 'Untitled'), u);
2232            const snippet = r.snippet ? highlightUsername(escAttr(r.snippet), u) : '';
2233            html += `
2234                <a class="dork-hit" href="${escAttr(r.url)}" target="_blank" rel="noopener noreferrer">
2235                    <div class="dork-hit-head">
2236                        <span class="dork-hit-title">${title}</span>
2237                        ${r.source_dork ? `<span class="dork-hit-tag">${escAttr(r.source_dork)}</span>` : ''}
2238                    </div>
2239                    <div class="dork-hit-url">${escAttr(r.domain || r.url)}</div>
2240                    ${snippet ? `<div class="dork-hit-snippet">${snippet}</div>` : ''}
2241                </a>`;
2242        });
2243        html += '</div>';
2244        sec.body.innerHTML = html;
2245        wrapper.appendChild(sec.wrapper);
2246    }
2247
2248    // ===== WATCHLIST =====
2249    const WATCHLIST_FREQUENCIES = [
2250        { value: 'daily',   icon: 'iconoir-sun-light',     label: 'Daily' },
2251        { value: 'weekly',  icon: 'iconoir-calendar',      label: 'Weekly' },
2252        { value: 'monthly', icon: 'iconoir-calendar-plus', label: 'Monthly' },
2253        { value: 'manual',  icon: 'iconoir-cursor-pointer',label: 'Manual' },
2254    ];
2255
2256    let selectedFrequency = 'weekly';
2257
2258    function buildFrequencyPicker() {
2259        return `<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;" id="watchlist-freq-picker">
2260            ${WATCHLIST_FREQUENCIES.map(f => `
2261                <button type="button" data-freq="${f.value}"
2262                    class="wl-freq-btn"
2263                    style="display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 8px;
2264                           border-radius:8px;border:2px solid ${f.value === selectedFrequency ? 'var(--accent)' : 'var(--border-color)'};
2265                           background:${f.value === selectedFrequency ? 'var(--accent-bg)' : 'var(--bg-surface-hover)'};
2266                           color:inherit;cursor:pointer;transition:all 0.15s;"
2267                    onclick="selectFrequency('${f.value}')">
2268                    <i class="${f.icon}" style="font-size:1.3rem;${f.value === selectedFrequency ? 'color:var(--accent);' : ''}"></i>
2269                    <span style="font-weight:600;font-size:0.9rem;">${f.label}</span>
2270                </button>
2271            `).join('')}
2272        </div>`;
2273    }
2274
2275    function selectFrequency(freq) {
2276        selectedFrequency = freq;
2277        document.querySelectorAll('.wl-freq-btn').forEach(btn => {
2278            const isSelected = btn.dataset.freq === freq;
2279            btn.style.borderColor = isSelected ? 'var(--accent)' : 'var(--border-color)';
2280            btn.style.background = isSelected ? 'var(--accent-bg)' : 'var(--bg-surface-hover)';
2281            const icon = btn.querySelector('i');
2282            if (icon) icon.style.color = isSelected ? 'var(--accent)' : '';
2283        });
2284
2285        // Update consent text for manual mode
2286        const costNote = document.getElementById('wl-cost-note');
2287        if (costNote) {
2288            costNote.textContent = freq === 'manual'
2289                ? 'Each manual check costs 1 token.'
2290                : 'Each automatic re-scan costs 1 token.';
2291        }
2292    }
2293
2294    function showWatchlistModal() {
2295        if (!currentUsername || resultsCount === 0) {
2296            appModal('Run a search before adding to the watchlist.', 'warning');
2297            return;
2298        }
2299
2300        selectedFrequency = 'weekly';
2301
2302        let existing = document.getElementById('watchlist-modal-overlay');
2303        if (existing) existing.remove();
2304
2305        const overlay = document.createElement('div');
2306        overlay.id = 'watchlist-modal-overlay';
2307        overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:var(--bg-scrim);z-index:10000;display:flex;align-items:center;justify-content:center;';
2308        overlay.innerHTML = `
2309            <div style="background:var(--bg-surface);border:1px solid var(--border-color);border-radius:12px;padding:30px;max-width:560px;width:90%;color:var(--text-primary);">
2310                <h3 style="margin:0 0 20px;display:flex;align-items:center;gap:8px;">
2311                    <i class="iconoir-bell"></i> Add to Watchlist
2312                </h3>
2313                <p>Monitor <strong>"${currentUsername}"</strong> and get notified by email when changes are detected.</p>
2314
2315                <div style="margin:15px 0;">
2316                    <label style="display:block;margin-bottom:10px;font-weight:600;">Check frequency</label>
2317                    ${buildFrequencyPicker()}
2318                </div>
2319
2320                <div style="margin:15px 0;">
2321                    <label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:10px 14px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-surface-hover);">
2322                        <input type="checkbox" id="watchlist-enrich" ${document.getElementById('enrich-checkbox')?.checked ? 'checked' : ''}>
2323                        <span style="font-size:0.9rem;"><i class="iconoir-search-engine"></i> Include enrichment — aggregate mentions, profiles &amp; emails from the web on each scan</span>
2324                        <span style="background:var(--accent);color:var(--text-on-accent);padding:1px 6px;border-radius:8px;font-size:0.75rem;">+1 tk/scan</span>
2325                    </label>
2326                </div>
2327
2328                <div style="background:var(--warning-bg);border:1px solid var(--warning-border);border-radius:8px;padding:15px;margin:15px 0;">
2329                    <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;">
2330                        <input type="checkbox" id="watchlist-consent" style="margin-top:3px;flex-shrink:0;">
2331                        <span style="font-size:0.9rem;">
2332                            I agree that search results will be stored <strong>encrypted</strong> (AES-256-GCM, envelope encryption DEK/KEK) in the database for comparison purposes.
2333                            Only my account can decrypt this data. <span id="wl-cost-note">Each automatic re-scan costs 1 token.</span>
2334                        </span>
2335                    </label>
2336                </div>
2337
2338                <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">
2339                    <button onclick="document.getElementById('watchlist-modal-overlay').remove()" class="btn btn-ghost">Cancel</button>
2340                    <button onclick="addToWatchlist()" class="btn btn-default" id="watchlist-submit-btn">
2341                        <i class="iconoir-bell"></i> Watch
2342                    </button>
2343                </div>
2344            </div>
2345        `;
2346        document.body.appendChild(overlay);
2347
2348        overlay.addEventListener('click', function(e) {
2349            if (e.target === overlay) overlay.remove();
2350        });
2351    }
2352
2353    function addToWatchlist() {
2354        const consent = document.getElementById('watchlist-consent').checked;
2355        if (!consent) {
2356            appModal('You must accept the encrypted storage terms.', 'warning');
2357            return;
2358        }
2359
2360        const btn = document.getElementById('watchlist-submit-btn');
2361        btn.disabled = true;
2362        btn.innerHTML = '<i class="iconoir-refresh-double"></i> Adding...';
2363
2364        fetch('/app/watchlist/add', {
2365            method: 'POST',
2366            headers: {
2367                'Content-Type': 'application/json',
2368                'X-CSRF-Token': 'FiprwQqLQ3TyfHoAK3S7DlOUN78MRF7ZXMrvADR4af0'
2369            },
2370            body: JSON.stringify({
2371                username: currentUsername,
2372                frequency: selectedFrequency,
2373                consent_accepted: true,
2374                enrich_dorks: document.getElementById('watchlist-enrich').checked,
2375                results: searchResults
2376            })
2377        })
2378        .then(r => r.json())
2379        .then(data => {
2380            document.getElementById('watchlist-modal-overlay').remove();
2381            if (data.success) {
2382                appModal(data.message || 'Added to watchlist!', 'success');
2383            } else {
2384                appModal(data.error || 'Error', 'error');
2385            }
2386        })
2387        .catch(err => {
2388            document.getElementById('watchlist-modal-overlay').remove();
2389            appModal('Network error: ' + err.message, 'error');
2390        });
2391    }
2392</script>
2392
2393
2394<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a44fb4274fd750d3',t:'MTc5MTA2OTkwMQ=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
2394<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"637ceef61b4f42548e03fc7b0a0bf6ee","r":1,"spa":2}' crossorigin="anonymous"></script>
2394
2395</body>
2396</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.