From 973d756e3b50c6d1da1b5761c5895974c3ff9eaa Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 12:11:30 -0400 Subject: [PATCH 01/21] Enrich TikTok, Fediverse/Mastodon, and Threads tools with metadata (THE-53) Populate enrichment fields (description, status, pricing, bestFor, input, output, opsec, opsecNote, and badge flags) for 12 social network tools: - TikTok: TikTok (M), TikTok-OSINT (T), Unfurl, yt-dlp (T) - Fediverse/Mastodon: Fedifinder, Fediverse Observer, Fediverse_OSINT (T), Masto (T) - Threads (new category): Bellingcat Meta Content Library, Threads Dashboard, Threads-Scraper (T), ThreadsRecon (T) Co-Authored-By: Paperclip --- public/arf.json | 246 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 2 deletions(-) diff --git a/public/arf.json b/public/arf.json index 086d965..8e01e4b 100644 --- a/public/arf.json +++ b/public/arf.json @@ -2691,7 +2691,88 @@ { "name": "Fediverse/Mastodon", "type": "folder", - "children": [] + "children": [ + { + "name": "Fedifinder", + "type": "url", + "url": "https://fedifinder.glitch.me/", + "description": "Web tool that scans your Twitter/X following list to find accounts that have migrated to the Fediverse by checking bios and pinned tweets for Mastodon handles.", + "status": "live", + "pricing": "free", + "bestFor": "Finding Twitter contacts who moved to Mastodon/Fediverse", + "input": "Twitter/X account (via OAuth login)", + "output": "CSV list of discovered Fediverse handles from your Twitter contacts", + "opsec": "active", + "opsecNote": "Requires Twitter OAuth authentication; scans public bios and tweets of followed accounts.", + "localInstall": false, + "googleDork": false, + "registration": true, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Fediverse Observer", + "type": "url", + "url": "https://fediverse.observer/", + "description": "Real-time dashboard tracking Fediverse instances across Mastodon, Pleroma, Misskey, PeerTube, and other ActivityPub platforms with server statistics and geographic mapping.", + "status": "live", + "pricing": "free", + "bestFor": "Discovering and mapping Fediverse instances by software, country, or size", + "input": "Search filters (software type, country, language, instance name)", + "output": "Instance list with user counts, uptime, software version, registration status, and geographic location", + "opsec": "passive", + "opsecNote": "Queries the Fediverse Observer database, not individual instances; no contact with target servers.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Fediverse_OSINT (T)", + "type": "url", + "url": "https://github.com/anantshri/fediverse_osint", + "description": "Open-source tool for searching and gathering intelligence across multiple Fediverse platforms including Mastodon, Pleroma, and Misskey instances.", + "status": "live", + "pricing": "free", + "bestFor": "Cross-instance Fediverse user and content search", + "input": "Username or search terms", + "output": "User profiles and posts found across Fediverse instances", + "opsec": "active", + "opsecNote": "Queries multiple Fediverse instances directly; requests may be logged by instance administrators.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Masto (T)", + "type": "url", + "url": "https://github.com/C3n7ral051nt4g3ncy/Masto", + "description": "Python-based Mastodon OSINT tool for investigating user accounts across instances. Retrieves profile details, toots, followers, and account metadata.", + "status": "live", + "pricing": "free", + "bestFor": "Mastodon user profile investigation and account analysis", + "input": "Mastodon username and instance (e.g., user@mastodon.social)", + "output": "Profile details, recent toots, follower/following lists, account creation date, and metadata", + "opsec": "active", + "opsecNote": "Makes API requests directly to the target Mastodon instance; instance admins can see request logs.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false + } + ] }, { "name": "Instagram", @@ -2935,7 +3016,82 @@ { "name": "TikTok (M)", "type": "url", - "url": "https://www.tiktok.com/@username" + "url": "https://www.tiktok.com/@username", + "description": "Direct TikTok profile lookup by manually editing the username in the URL. Quick way to check if a target has a TikTok presence.", + "status": "live", + "pricing": "free", + "bestFor": "Quick TikTok profile existence check", + "input": "TikTok username (replace 'username' in URL)", + "output": "Public TikTok profile page with videos, bio, and follower counts", + "opsec": "passive", + "opsecNote": "Standard web request to TikTok; profile views are not visible to the account owner without login.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "TikTok-OSINT (T)", + "type": "url", + "url": "https://github.com/Omicron166/TikTok-OSINT", + "description": "Python CLI tool for collecting OSINT data from TikTok profiles including user metadata, video listings, and engagement statistics.", + "status": "live", + "pricing": "free", + "bestFor": "Automated TikTok profile data collection", + "input": "TikTok username or profile URL", + "output": "User metadata, video listings, engagement stats, and follower data", + "opsec": "active", + "opsecNote": "Makes direct API requests to TikTok servers; may trigger rate limiting.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Unfurl", + "type": "url", + "url": "https://dfir.blog/unfurl/", + "description": "Web-based tool that extracts and visualizes metadata embedded in URLs, including TikTok share links, timestamps, and tracking parameters.", + "status": "live", + "pricing": "free", + "bestFor": "URL metadata extraction and timestamp analysis", + "input": "Any URL (including TikTok share/video links)", + "output": "Visual graph of extracted URL components, timestamps, and embedded identifiers", + "opsec": "passive", + "opsecNote": "Parses URL structure locally in browser or on the Unfurl server; does not contact the target platform.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "yt-dlp (T)", + "type": "url", + "url": "https://github.com/yt-dlp/yt-dlp", + "description": "Feature-rich command-line video downloader supporting 1000+ sites including TikTok. Extracts video files, metadata, thumbnails, subtitles, and comments.", + "status": "live", + "pricing": "free", + "bestFor": "Downloading TikTok videos and extracting metadata for preservation", + "input": "TikTok video URL or user profile URL", + "output": "Downloaded video files, JSON metadata, thumbnails, subtitles, and comments", + "opsec": "active", + "opsecNote": "Makes direct HTTP requests to TikTok to download content; IP is visible to the platform.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false } ] }, @@ -2950,6 +3106,92 @@ } ] }, + { + "name": "Threads", + "type": "folder", + "children": [ + { + "name": "Bellingcat Meta Content Library", + "type": "url", + "url": "https://bellingcat.gitbook.io/toolkit/more/all-tools/meta-content-library", + "description": "Bellingcat's guide to Meta's Content Library, a research platform providing near-real-time searchable archive of public Threads, Facebook, and Instagram content.", + "status": "live", + "pricing": "free", + "bestFor": "Large-scale Threads content search for academic and non-profit research", + "input": "Search queries, date ranges, content filters", + "output": "Public posts, engagement metrics, and text-in-image search results from Threads and other Meta platforms", + "opsec": "passive", + "opsecNote": "Searches Meta's research archive; does not interact with target accounts directly. Requires academic/non-profit eligibility approval.", + "localInstall": false, + "googleDork": false, + "registration": true, + "editUrl": false, + "api": true, + "invitationOnly": true, + "deprecated": false + }, + { + "name": "Threads Dashboard", + "type": "url", + "url": "https://www.threadsdashboard.com/", + "description": "Analytics and insights platform for Threads accounts using the official API. Tracks audience demographics, engagement metrics, and historical posting data.", + "status": "live", + "pricing": "freemium", + "bestFor": "Threads account analytics and engagement investigation", + "input": "Threads username or account URL", + "output": "Audience demographics, engagement rates, posting frequency, optimal posting times, and historical data", + "opsec": "passive", + "opsecNote": "Retrieves data via Meta's official Threads API; no direct contact with the target account.", + "localInstall": false, + "googleDork": false, + "registration": true, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "Threads-Scraper (T)", + "type": "url", + "url": "https://github.com/Zeeshanahmad4/Threads-Scraper", + "description": "Open-source data extraction tool for Threads posts with JSON, CSV, and XML export. Collects posts at 300-600 per hour without authentication.", + "status": "live", + "pricing": "free", + "bestFor": "Bulk extraction of Threads posts for offline analysis", + "input": "Threads profile URL or post URL", + "output": "Extracted posts in JSON, CSV, or XML format with metadata", + "opsec": "active", + "opsecNote": "Scrapes Threads directly; depends on site structure and may trigger rate limiting.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, + { + "name": "ThreadsRecon (T)", + "type": "url", + "url": "https://github.com/offseq/threadsrecon", + "description": "Python OSINT tool for Threads profile analysis including sentiment analysis, network visualization, and automated PDF reporting.", + "status": "live", + "pricing": "free", + "bestFor": "Threads profile investigation with sentiment and network analysis", + "input": "Threads username", + "output": "Profile analysis, sentiment scores, network graphs, and PDF investigation reports", + "opsec": "active", + "opsecNote": "Makes direct requests to Threads to collect profile and post data for analysis.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + } + ] + }, { "name": "Steam, Discord & Gaming Networks", "type": "folder", From 5a28d0ce51685d3a2aea4ba41600a00506d4107e Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 12:13:30 -0400 Subject: [PATCH 02/21] Fix Fediverse tool accuracy: Fedifinder down, Fediverse_OSINT URL corrected (THE-53) - Fedifinder: marked status=down, deprecated=true (HTTP 410, Twitter API restrictions) - Fediverse_OSINT: corrected URL from anantshri to cyfinoid/fediverse_osint, marked status=degraded (last updated Nov 2022) Co-Authored-By: Paperclip --- public/arf.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/arf.json b/public/arf.json index 8e01e4b..460cdf4 100644 --- a/public/arf.json +++ b/public/arf.json @@ -2696,8 +2696,8 @@ "name": "Fedifinder", "type": "url", "url": "https://fedifinder.glitch.me/", - "description": "Web tool that scans your Twitter/X following list to find accounts that have migrated to the Fediverse by checking bios and pinned tweets for Mastodon handles.", - "status": "live", + "description": "Web tool that scanned Twitter profiles to find Fediverse/Mastodon handles among your contacts and exported them as CSV. The hosted instance now returns HTTP 410 and is no longer functional due to Twitter API restrictions.", + "status": "down", "pricing": "free", "bestFor": "Finding Twitter contacts who moved to Mastodon/Fediverse", "input": "Twitter/X account (via OAuth login)", @@ -2710,7 +2710,7 @@ "editUrl": false, "api": false, "invitationOnly": false, - "deprecated": false + "deprecated": true }, { "name": "Fediverse Observer", @@ -2735,9 +2735,9 @@ { "name": "Fediverse_OSINT (T)", "type": "url", - "url": "https://github.com/anantshri/fediverse_osint", - "description": "Open-source tool for searching and gathering intelligence across multiple Fediverse platforms including Mastodon, Pleroma, and Misskey instances.", - "status": "live", + "url": "https://github.com/cyfinoid/fediverse_osint", + "description": "Python CLI tool for checking whether a domain belongs to the Fediverse and hunting usernames across discoverable Fediverse servers.", + "status": "degraded", "pricing": "free", "bestFor": "Cross-instance Fediverse user and content search", "input": "Username or search terms", From db6bb9bd4dd8263ef5a868718b2216c9112bb6d6 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 12:14:46 -0400 Subject: [PATCH 03/21] Fix TikTok and Unfurl tool accuracy (THE-53) - TikTok-OSINT (T): marked status=degraded (last updated Oct 2022) - Unfurl: set localInstall=true (Python package available via pip) Co-Authored-By: Paperclip --- public/arf.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public/arf.json b/public/arf.json index 460cdf4..770ccdc 100644 --- a/public/arf.json +++ b/public/arf.json @@ -3037,8 +3037,8 @@ "name": "TikTok-OSINT (T)", "type": "url", "url": "https://github.com/Omicron166/TikTok-OSINT", - "description": "Python CLI tool for collecting OSINT data from TikTok profiles including user metadata, video listings, and engagement statistics.", - "status": "live", + "description": "Python CLI tool for collecting OSINT data from TikTok profiles including user metadata, video listings, and engagement statistics. Last updated October 2022.", + "status": "degraded", "pricing": "free", "bestFor": "Automated TikTok profile data collection", "input": "TikTok username or profile URL", @@ -3057,15 +3057,15 @@ "name": "Unfurl", "type": "url", "url": "https://dfir.blog/unfurl/", - "description": "Web-based tool that extracts and visualizes metadata embedded in URLs, including TikTok share links, timestamps, and tracking parameters.", + "description": "Web and CLI tool that extracts and visualizes metadata embedded in URLs, including TikTok share links, timestamps, and tracking parameters. Also installable locally via Python.", "status": "live", "pricing": "free", "bestFor": "URL metadata extraction and timestamp analysis", "input": "Any URL (including TikTok share/video links)", "output": "Visual graph of extracted URL components, timestamps, and embedded identifiers", "opsec": "passive", - "opsecNote": "Parses URL structure locally in browser or on the Unfurl server; does not contact the target platform.", - "localInstall": false, + "opsecNote": "Parses URL structure locally in browser or on the Unfurl server; does not contact the target platform. Local install available for sensitive investigations.", + "localInstall": true, "googleDork": false, "registration": false, "editUrl": false, From dd672b1fa9142b887d11c6c2085e1ef656ff8ce6 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 12:15:15 -0400 Subject: [PATCH 04/21] Mark Threads-Scraper as degraded (THE-53) Last commit July 2023; likely broken due to Threads frontend changes since. Co-Authored-By: Paperclip --- public/arf.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/arf.json b/public/arf.json index 770ccdc..b8b4568 100644 --- a/public/arf.json +++ b/public/arf.json @@ -3154,8 +3154,8 @@ "name": "Threads-Scraper (T)", "type": "url", "url": "https://github.com/Zeeshanahmad4/Threads-Scraper", - "description": "Open-source data extraction tool for Threads posts with JSON, CSV, and XML export. Collects posts at 300-600 per hour without authentication.", - "status": "live", + "description": "Python browser automation tool that scrapes public Threads posts and profiles without authentication, outputting structured data in JSON, CSV, or XML. Last updated July 2023.", + "status": "degraded", "pricing": "free", "bestFor": "Bulk extraction of Threads posts for offline analysis", "input": "Threads profile URL or post URL", From 2db8276c0969a0ddecff20c86b48f00e13237383 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 14:57:19 -0400 Subject: [PATCH 05/21] Add api, invitationOnly, deprecated badges to tool panel (THE-116) Show boolean feature badges in the tool details panel when true: - API (blue): tool has a public API - Invite Only (amber): tool requires an invitation - Deprecated (dark red): tool is no longer actively maintained Co-Authored-By: Paperclip --- public/css/panel.css | 7 ++++++- public/js/arf.js | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/public/css/panel.css b/public/css/panel.css index 7128b1c..aa0bd1c 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -335,7 +335,12 @@ .badge-active { background: #c84040; } /* Unknown / fallback */ -.badge-unknown { background: #606060; } +.badge-unknown { background: #606060; } + +/* Boolean feature badges */ +.badge-api { background: #1e90cc; } +.badge-invitation-only { background: #b87800; } +.badge-deprecated { background: #8b3030; } /* OPSEC row layout */ .opsec-row { diff --git a/public/js/arf.js b/public/js/arf.js index a935de5..2234b2b 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -451,6 +451,16 @@ function openPanel(d) { var pricingClass = ["free","freemium","paid"].indexOf(pricing) !== -1 ? pricing : "unknown"; badgeHtml += ' ' + escapeHtml(pricing) + ''; + if (d.data.api === true) { + badgeHtml += ' API'; + } + if (d.data.invitationOnly === true) { + badgeHtml += ' Invite Only'; + } + if (d.data.deprecated === true) { + badgeHtml += ' Deprecated'; + } + badgesEl.innerHTML = badgeHtml; badgesEl.classList.remove("empty"); } From 591ded3875cfa479c72fad562d17044176a23580 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 15:01:41 -0400 Subject: [PATCH 06/21] Add nsfw badge to tool panel (THE-116) Co-Authored-By: Paperclip --- public/css/panel.css | 1 + public/js/arf.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/public/css/panel.css b/public/css/panel.css index aa0bd1c..8ee99fc 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -341,6 +341,7 @@ .badge-api { background: #1e90cc; } .badge-invitation-only { background: #b87800; } .badge-deprecated { background: #8b3030; } +.badge-nsfw { background: #9b2b9b; } /* OPSEC row layout */ .opsec-row { diff --git a/public/js/arf.js b/public/js/arf.js index 2234b2b..e6fd43b 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -460,6 +460,9 @@ function openPanel(d) { if (d.data.deprecated === true) { badgeHtml += ' Deprecated'; } + if (d.data.nsfw === true) { + badgeHtml += ' NSFW'; + } badgesEl.innerHTML = badgeHtml; badgesEl.classList.remove("empty"); From 05997513b54fe841f5fff91f8689aec8a5e6e681 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 15:04:12 -0400 Subject: [PATCH 07/21] Add region badge to tool panel (THE-116) Adds a region badge that always shows in the tool details panel. Defaults to "Global" when the region field is absent or set to "global". Accepts a two-letter country code string (uppercased) or an array of country codes (joined as "US, UK"). Badge is styled green (#2e7d32). Co-Authored-By: Paperclip --- public/css/panel.css | 1 + public/js/arf.js | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/public/css/panel.css b/public/css/panel.css index 8ee99fc..3d9e488 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -342,6 +342,7 @@ .badge-invitation-only { background: #b87800; } .badge-deprecated { background: #8b3030; } .badge-nsfw { background: #9b2b9b; } +.badge-region { background: #2e7d32; } /* OPSEC row layout */ .opsec-row { diff --git a/public/js/arf.js b/public/js/arf.js index e6fd43b..e7cd87b 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -464,6 +464,17 @@ function openPanel(d) { badgeHtml += ' NSFW'; } + var region = d.data.region; + var regionLabel; + if (!region || region === "global") { + regionLabel = "Global"; + } else if (Array.isArray(region)) { + regionLabel = region.map(function(r) { return r.toUpperCase(); }).join(", "); + } else { + regionLabel = String(region).toUpperCase(); + } + badgeHtml += ' ' + escapeHtml(regionLabel) + ''; + badgesEl.innerHTML = badgeHtml; badgesEl.classList.remove("empty"); } From de13225292b35fc45cbf68d07e4c8c5d49bdf2aa Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 17:36:32 -0400 Subject: [PATCH 08/21] Allow clicking different tool while detail panel is open (THE-117) The panel overlay (z-index 199) was intercepting all clicks on the tree when the panel was open, forcing users to close the panel before selecting a different tool. Setting pointer-events: none on the overlay lets clicks pass through to tree nodes, where openPanel() already handles replacing the panel content. The SVG background click handler still closes the panel when clicking empty canvas. Co-Authored-By: Paperclip --- public/css/panel.css | 1 + public/js/arf.js | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/public/css/panel.css b/public/css/panel.css index 7128b1c..3d13002 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -11,6 +11,7 @@ #panel-overlay.visible { display: block; + pointer-events: none; } /* Panel container */ diff --git a/public/js/arf.js b/public/js/arf.js index a935de5..46e867f 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -589,13 +589,10 @@ document.addEventListener("keydown", function(e) { if (e.key === "Escape") closePanel(); }); -// Wire close button and overlay once DOM is ready +// Wire close button once DOM is ready document.addEventListener("DOMContentLoaded", function() { var closeBtn = document.getElementById("panel-close"); if (closeBtn) closeBtn.addEventListener("click", closePanel); - - var overlay = document.getElementById("panel-overlay"); - if (overlay) overlay.addEventListener("click", closePanel); }); // Canvas click: close panel when clicking the SVG background (not a node) From c4531cb05346b0845b129dbd65234a28b0ad4748 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 18:12:49 -0400 Subject: [PATCH 09/21] Add mobile-only theme toggle in header and notes overlay panel (THE-118) On mobile (<=768px), the legend, notes, and about sections consumed most of the viewport, leaving the D3 tree nearly unusable. This moves the theme toggle into the header as a compact "Light Mode"/"Dark Mode" button, hides the below-tree content, and adds a floating "Notes" button that opens all that content in a full-screen overlay panel (matching the existing tool detail panel pattern). Desktop layout is completely unchanged. Co-Authored-By: Paperclip --- public/css/arf.css | 169 +++++++++++++++++++++++++++++++++++++++++++-- public/index.html | 41 +++++++---- public/js/arf.js | 35 ++++++++++ 3 files changed, 228 insertions(+), 17 deletions(-) diff --git a/public/css/arf.css b/public/css/arf.css index 18153d4..b539fce 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -260,18 +260,177 @@ a { text-align: center; } +/* Mobile theme toggle (hidden on desktop) */ +#mobile-theme-toggle { + display: none; +} + +/* Mobile notes button (hidden on desktop) */ +#mobile-notes-btn { + display: none; +} + +/* Notes overlay (hidden on desktop) */ +#notes-overlay { + display: none; +} + +/* Notes panel (hidden on desktop) */ +#notes-panel { + display: none; +} + /* === Mobile responsive === */ @media (max-width: 768px) { #header { font-size: var(--font-size-lg); top: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); } + #header hr { + display: none; + } + + #header-title { + flex: 1; + } + + #mobile-theme-toggle { + display: inline-block; + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-family: var(--font-family); + padding: var(--space-1) var(--space-2); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + } + + #mobile-theme-toggle:hover { + color: var(--color-text-primary); + border-color: var(--color-text-secondary); + } + + /* Hide legend and notes content on mobile */ .legend { - position: relative; - width: auto; - max-width: 100%; - padding: var(--space-2) var(--space-3); - box-sizing: border-box; + display: none; + } + + #notes-content { + display: none; + } + + /* Floating notes button */ + #mobile-notes-btn { + display: block; + position: fixed; + bottom: var(--space-4); + left: 50%; + transform: translateX(-50%); + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 20px; + padding: var(--space-2) var(--space-5); + font-size: var(--font-size-base); + font-family: var(--font-family); + font-weight: bold; + cursor: pointer; + box-shadow: var(--shadow-md); + z-index: 100; + } + + #mobile-notes-btn:hover { + background: var(--color-bg); + } + + /* Notes overlay */ + #notes-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 299; + } + + #notes-overlay.visible { + display: block; + } + + /* Notes panel */ + #notes-panel { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + width: 100vw; + height: 100vh; + background: var(--color-surface); + z-index: 300; + transform: translateX(100%); + transition: transform 250ms ease; + will-change: transform; + } + + #notes-panel.open { + transform: translateX(0); + } + + #notes-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + } + + #notes-panel-title { + font-size: var(--font-size-lg); + font-weight: bold; + color: var(--color-text-primary); + } + + #notes-panel-close { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 2px var(--space-1); + border-radius: var(--radius-sm); + } + + #notes-panel-close:hover { + color: var(--color-text-primary); + background: var(--color-bg); + } + + #notes-panel-body { + flex: 1; + padding: var(--space-4); + overflow-y: auto; + font-size: var(--font-size-base); + color: var(--color-text-primary); + line-height: 1.6; + } + + #notes-panel-body h3 { + margin-top: var(--space-4); + margin-bottom: var(--space-2); + } + + #notes-panel-body h3:first-child { + margin-top: 0; } } diff --git a/public/index.html b/public/index.html index 236bab2..a968bdd 100644 --- a/public/index.html +++ b/public/index.html @@ -26,7 +26,8 @@
@@ -106,25 +107,41 @@
-

Notes

- OSINT framework focused on gathering information from free tools or resources. The intention is to help people find free OSINT resources. Some of the sites included might require registration or offer more data for $$$, but you should be able to get at least a portion of the available information for no cost.
+
+

Notes

+ OSINT framework focused on gathering information from free tools or resources. The intention is to help people find free OSINT resources. Some of the sites included might require registration or offer more data for $$$, but you should be able to get at least a portion of the available information for no cost.
-

I originally created this framework with an information security point of view. Since then, the response from other fields and disciplines has been incredible. I would love to be able to include any other OSINT resources, especially from fields outside of infosec. Please let me know about anything that might be missing!

+

I originally created this framework with an information security point of view. Since then, the response from other fields and disciplines has been incredible. I would love to be able to include any other OSINT resources, especially from fields outside of infosec. Please let me know about anything that might be missing!

-

For Update Notifications

- Follow me on Twitter: @jnordine
- Watch or star the project on Github: https://github.com/lockfale/osint-framework +

For Update Notifications

+ Follow me on Twitter: @jnordine
+ Watch or star the project on Github: https://github.com/lockfale/osint-framework -

Suggestions, Comments, Feedback

- Feedback or new tool suggestions are extremely welcome! Please feel free to reach out on Twitter or submit an issue on Github. +

Suggestions, Comments, Feedback

+ Feedback or new tool suggestions are extremely welcome! Please feel free to reach out on Twitter or submit an issue on Github. +
+ + + + + +
+
+
+ Notes + +
+
+
diff --git a/public/js/arf.js b/public/js/arf.js index 3b05819..4ad1503 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -766,6 +766,41 @@ function goDark() { if (btn) { btn.textContent = isLight ? "Switch to dark mode" : "Switch to light mode"; } + var mBtn = document.getElementById("mobile-theme-toggle"); + if (mBtn) { + mBtn.textContent = isLight ? "Dark Mode" : "Light Mode"; + } // Re-render to pick up new CSS variable values for D3 inline styles. update(root); } + +// Mobile notes panel toggle +function toggleNotesPanel() { + var panel = document.getElementById("notes-panel"); + var overlay = document.getElementById("notes-overlay"); + if (!panel) return; + + var isOpen = panel.classList.toggle("open"); + if (overlay) overlay.classList.toggle("visible", isOpen); + + // Populate panel body on first open + if (isOpen && !panel._populated) { + var body = document.getElementById("notes-panel-body"); + var source = document.getElementById("notes-content"); + var legend = document.querySelector(".legend"); + if (body) { + body.innerHTML = ""; + if (legend) body.innerHTML += legend.innerHTML; + if (source) body.innerHTML += source.innerHTML; + } + panel._populated = true; + } +} + +// Close notes panel when overlay is clicked +(function() { + var overlay = document.getElementById("notes-overlay"); + if (overlay) { + overlay.addEventListener("click", function() { toggleNotesPanel(); }); + } +})(); From 56e65f27ac9abaeadc463291cbfe691c42242483 Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 18:24:44 -0400 Subject: [PATCH 10/21] Center title and maximize tree viewport on mobile (THE-118) - Use position:absolute for theme toggle so title centers naturally - Reduce search bar padding on mobile for more vertical space - Add min-height: 60vh on SVG to ensure tree fills viewport - Desktop layout unchanged Co-Authored-By: Paperclip --- public/css/arf.css | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/public/css/arf.css b/public/css/arf.css index b539fce..b0f6f03 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -284,11 +284,9 @@ a { @media (max-width: 768px) { #header { font-size: var(--font-size-lg); - top: 10px; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-3); + top: 0; + position: relative; + text-align: center; padding: var(--space-2) var(--space-3); } @@ -296,12 +294,12 @@ a { display: none; } - #header-title { - flex: 1; - } - #mobile-theme-toggle { display: inline-block; + position: absolute; + right: var(--space-3); + top: 50%; + transform: translateY(-50%); background: none; border: 1px solid var(--color-border); border-radius: var(--radius-sm); @@ -311,7 +309,6 @@ a { padding: var(--space-1) var(--space-2); cursor: pointer; white-space: nowrap; - flex-shrink: 0; } #mobile-theme-toggle:hover { @@ -319,6 +316,15 @@ a { border-color: var(--color-text-secondary); } + #search-container { + padding: var(--space-1) 0; + } + + #search-input { + padding: var(--space-1) var(--space-2); + font-size: 13px; + } + /* Hide legend and notes content on mobile */ .legend { display: none; @@ -328,6 +334,11 @@ a { display: none; } + /* Maximize SVG tree viewport */ + svg { + min-height: 60vh; + } + /* Floating notes button */ #mobile-notes-btn { display: block; From 4b34fcdb2a0385f2f32bc9709af05c7d2e99cc5b Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 18:37:33 -0400 Subject: [PATCH 11/21] Zoom to fill viewport on mobile initial load (THE-119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On screens ≤768px, pre-compute the bounding box of the collapsed tree layout and apply a zoom transform so the root + top-level categories fill the viewport immediately, instead of starting tiny in the center. Also adds a reusable zoomToFill() utility for future reset-view use. Co-Authored-By: Paperclip --- public/js/arf.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/public/js/arf.js b/public/js/arf.js index 4ad1503..d83ae7b 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -83,6 +83,31 @@ d3.json("arf.json").then(function(json) { } root.children.forEach(collapse); + + // On mobile, pre-compute zoom so the collapsed tree fills the viewport. + if (window.innerWidth <= 768) { + // Run tree layout to get final node positions, then compute zoom from data. + tree(root); + root.descendants().forEach(function(d) { d.y = d.depth * 180; }); + var visibleNodes = root.descendants(); + var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + visibleNodes.forEach(function(d) { + if (d.x < minX) minX = d.x; + if (d.x > maxX) maxX = d.x; + if (d.y < minY) minY = d.y; + if (d.y > maxY) maxY = d.y; + }); + var pad = 60; + var bw = (maxY - minY) || 1; + var bh = (maxX - minX) || 1; + var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); + var cx = (minY + maxY) / 2; + var cy = (minX + maxX) / 2; + var tx = svgW / 2 - margin[3] - cx * k; + var ty = svgH / 2 - margin[0] - cy * k; + svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); + } + update(root); initSearch(); }); @@ -235,6 +260,19 @@ function toggle(d) { } } +// Zoom the tree so visible nodes fill the SVG viewport (used on mobile init). +function zoomToFill() { + var bbox = vis.node().getBBox(); + if (!bbox.width || !bbox.height) return; + var pad = 40; + var scaleX = (svgW - pad * 2) / bbox.width; + var scaleY = (svgH - pad * 2) / bbox.height; + var k = Math.min(scaleX, scaleY, 3); + var tx = svgW / 2 - (bbox.x + bbox.width / 2) * k - margin[3]; + var ty = svgH / 2 - (bbox.y + bbox.height / 2) * k - margin[0]; + svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); +} + // Auto-pan viewport to center on a node after expand/click. function zoomToNode(d) { var currentK = d3.zoomTransform(svgEl.node()).k; From bc2387831dceff28f5f23165d4b155113f0ed38c Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 19:08:55 -0400 Subject: [PATCH 12/21] Zoom to fill viewport on mobile initial load (THE-119) Adjust the SVG viewBox on mobile to match the actual rendered aspect ratio instead of the fixed 1280x800 landscape ratio. The previous implementation computed the zoom scale against the viewBox dimensions, but on a tall/narrow mobile screen with min-height: 60vh the SVG element is much taller than the viewBox maps to with xMidYMid meet. By stretching the viewBox to match the real element proportions, the tree's vertical extent now has proper headroom and the initial zoom fills the viewport as expected. Co-Authored-By: Paperclip --- public/js/arf.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/public/js/arf.js b/public/js/arf.js index d83ae7b..3dce43b 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -84,8 +84,16 @@ d3.json("arf.json").then(function(json) { root.children.forEach(collapse); - // On mobile, pre-compute zoom so the collapsed tree fills the viewport. + // On mobile, stretch the viewBox to match the actual rendered aspect ratio + // so the zoom-to-fill calculation uses the real visible area, not the + // fixed 1280x800 letterboxed region. if (window.innerWidth <= 768) { + var rect = svgEl.node().getBoundingClientRect(); + if (rect.width && rect.height) { + svgH = Math.round(svgW * (rect.height / rect.width)); + svgEl.attr("viewBox", "0 0 " + svgW + " " + svgH); + } + // Run tree layout to get final node positions, then compute zoom from data. tree(root); root.descendants().forEach(function(d) { d.y = d.depth * 180; }); @@ -97,7 +105,7 @@ d3.json("arf.json").then(function(json) { if (d.y < minY) minY = d.y; if (d.y > maxY) maxY = d.y; }); - var pad = 60; + var pad = 40; var bw = (maxY - minY) || 1; var bh = (maxX - minX) || 1; var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); From 4e5e88cf0b05e7ddfc9b2d93df33653e710ff8af Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 19:54:31 -0400 Subject: [PATCH 13/21] Add Notes and theme toggle buttons to desktop header (THE-120) Move theme toggle from legend to header as "Light Mode"/"Dark Mode" button visible on both desktop and mobile. Add "Notes" button in header (desktop only; mobile keeps floating pill). Legend and notes content now load into a 400px slide-in side panel on desktop, matching the full-screen mobile overlay. Escape key closes notes panel. Co-Authored-By: Paperclip --- public/css/arf.css | 247 +++++++++++++++++++++++---------------------- public/index.html | 12 +-- public/js/arf.js | 21 ++-- 3 files changed, 144 insertions(+), 136 deletions(-) diff --git a/public/css/arf.css b/public/css/arf.css index b0f6f03..3e4e894 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -103,20 +103,51 @@ svg:active { } #header { + display: flex; + align-items: center; + justify-content: center; + position: relative; font-size: var(--font-size-xl); font-weight: bold; top: 20px; text-align: center; color: var(--color-header); + padding: var(--space-2) var(--space-4); +} + +#header-actions { + position: absolute; + right: var(--space-4); + top: 50%; + transform: translateY(-50%); + display: flex; + gap: var(--space-2); + align-items: center; +} + +#header-actions button { + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-family: var(--font-family); + padding: var(--space-1) var(--space-2); + cursor: pointer; + white-space: nowrap; +} + +#header-actions button:hover { + color: var(--color-text-primary); + border-color: var(--color-text-secondary); } .legend { - position: absolute; - top: 0px; - right: 0; - width: 330px; - font-size: var(--font-size-sm); - color: var(--color-legend); + display: none; +} + +#notes-content { + display: none; } .legend a { @@ -260,24 +291,97 @@ a { text-align: center; } -/* Mobile theme toggle (hidden on desktop) */ -#mobile-theme-toggle { - display: none; -} - -/* Mobile notes button (hidden on desktop) */ +/* Mobile floating notes button (hidden on desktop; header button used instead) */ #mobile-notes-btn { display: none; } -/* Notes overlay (hidden on desktop) */ +/* Notes overlay */ #notes-overlay { display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 299; } -/* Notes panel (hidden on desktop) */ +#notes-overlay.visible { + display: block; +} + +/* Notes panel (desktop: side panel) */ #notes-panel { - display: none; + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + width: 400px; + max-width: 100vw; + height: 100vh; + background: var(--color-surface); + z-index: 300; + transform: translateX(100%); + transition: transform 250ms ease; + will-change: transform; + box-shadow: var(--shadow-md); +} + +#notes-panel.open { + transform: translateX(0); +} + +#notes-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +#notes-panel-title { + font-size: var(--font-size-lg); + font-weight: bold; + color: var(--color-text-primary); +} + +#notes-panel-close { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 2px var(--space-1); + border-radius: var(--radius-sm); +} + +#notes-panel-close:hover { + color: var(--color-text-primary); + background: var(--color-bg); +} + +#notes-panel-body { + flex: 1; + padding: var(--space-4); + overflow-y: auto; + font-size: var(--font-size-base); + color: var(--color-text-primary); + line-height: 1.6; +} + +#notes-panel-body a { + color: var(--color-node-stroke); +} + +#notes-panel-body h3 { + margin-top: var(--space-4); + margin-bottom: var(--space-2); +} + +#notes-panel-body h3:first-child { + margin-top: 0; } /* === Mobile responsive === */ @@ -286,7 +390,6 @@ a { font-size: var(--font-size-lg); top: 0; position: relative; - text-align: center; padding: var(--space-2) var(--space-3); } @@ -294,26 +397,13 @@ a { display: none; } - #mobile-theme-toggle { - display: inline-block; - position: absolute; + #header-actions { right: var(--space-3); - top: 50%; - transform: translateY(-50%); - background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - color: var(--color-text-secondary); - font-size: var(--font-size-sm); - font-family: var(--font-family); - padding: var(--space-1) var(--space-2); - cursor: pointer; - white-space: nowrap; } - #mobile-theme-toggle:hover { - color: var(--color-text-primary); - border-color: var(--color-text-secondary); + /* Hide desktop notes button on mobile (floating button used instead) */ + #header-notes-btn { + display: none; } #search-container { @@ -325,21 +415,12 @@ a { font-size: 13px; } - /* Hide legend and notes content on mobile */ - .legend { - display: none; - } - - #notes-content { - display: none; - } - /* Maximize SVG tree viewport */ svg { min-height: 60vh; } - /* Floating notes button */ + /* Floating notes button (mobile only) */ #mobile-notes-btn { display: block; position: fixed; @@ -363,85 +444,9 @@ a { background: var(--color-bg); } - /* Notes overlay */ - #notes-overlay { - display: none; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 299; - } - - #notes-overlay.visible { - display: block; - } - - /* Notes panel */ + /* Full-width notes panel on mobile */ #notes-panel { - display: flex; - flex-direction: column; - position: fixed; - top: 0; - right: 0; width: 100vw; - height: 100vh; - background: var(--color-surface); - z-index: 300; - transform: translateX(100%); - transition: transform 250ms ease; - will-change: transform; - } - - #notes-panel.open { - transform: translateX(0); - } - - #notes-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-4); - border-bottom: 1px solid var(--color-border); - flex-shrink: 0; - } - - #notes-panel-title { - font-size: var(--font-size-lg); - font-weight: bold; - color: var(--color-text-primary); - } - - #notes-panel-close { - background: none; - border: none; - color: var(--color-text-secondary); - font-size: 20px; - line-height: 1; - cursor: pointer; - padding: 2px var(--space-1); - border-radius: var(--radius-sm); - } - - #notes-panel-close:hover { - color: var(--color-text-primary); - background: var(--color-bg); - } - - #notes-panel-body { - flex: 1; - padding: var(--space-4); - overflow-y: auto; - font-size: var(--font-size-base); - color: var(--color-text-primary); - line-height: 1.6; - } - - #notes-panel-body h3 { - margin-top: var(--space-4); - margin-bottom: var(--space-2); - } - - #notes-panel-body h3:first-child { - margin-top: 0; + box-shadow: none; } } diff --git a/public/index.html b/public/index.html index a968bdd..4abff3c 100644 --- a/public/index.html +++ b/public/index.html @@ -27,7 +27,10 @@
@@ -104,14 +107,11 @@ R - Requires registration
M - Indicates a URL that contains the search term and the URL itself must be edited manually

-
diff --git a/public/js/arf.js b/public/js/arf.js index 3dce43b..7bbe86a 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -654,9 +654,16 @@ function closePanel() { if (root) update(root); } -// Keyboard: Escape closes panel +// Keyboard: Escape closes panels document.addEventListener("keydown", function(e) { - if (e.key === "Escape") closePanel(); + if (e.key === "Escape") { + var notesPanel = document.getElementById("notes-panel"); + if (notesPanel && notesPanel.classList.contains("open")) { + toggleNotesPanel(); + } else { + closePanel(); + } + } }); // Wire close button once DOM is ready @@ -808,19 +815,15 @@ function goDark() { var body = document.body; var isLight = body.classList.toggle("light-mode"); localStorage.setItem("theme", isLight ? "light" : "dark"); - var btn = document.getElementById("theme-toggle"); + var btn = document.getElementById("header-theme-toggle"); if (btn) { - btn.textContent = isLight ? "Switch to dark mode" : "Switch to light mode"; - } - var mBtn = document.getElementById("mobile-theme-toggle"); - if (mBtn) { - mBtn.textContent = isLight ? "Dark Mode" : "Light Mode"; + btn.textContent = isLight ? "Dark Mode" : "Light Mode"; } // Re-render to pick up new CSS variable values for D3 inline styles. update(root); } -// Mobile notes panel toggle +// Notes panel toggle function toggleNotesPanel() { var panel = document.getElementById("notes-panel"); var overlay = document.getElementById("notes-overlay"); From 810b6f6159488b08bd7c9a27adcb469788d0f59a Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 20:02:00 -0400 Subject: [PATCH 14/21] Center header title and fit tree to browser viewport on desktop (THE-120) - Remove
from header flex container that broke title centering - Apply viewport-fit zoom logic on all screen sizes, not just mobile - Set SVG height to calc(100vh - 120px) so tree fills available space - Add overflow:hidden to body to prevent scrolling past viewport - Remove unnecessary top:20px offset from header Co-Authored-By: Paperclip --- public/css/arf.css | 12 ++++------ public/index.html | 1 - public/js/arf.js | 58 ++++++++++++++++++++++------------------------ 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/public/css/arf.css b/public/css/arf.css index 3e4e894..2c52415 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -70,6 +70,8 @@ body { color: var(--color-text-primary); font-size: var(--font-size-base); font-family: var(--font-family); + margin: 0; + overflow: hidden; } noscript p { @@ -94,7 +96,8 @@ noscript p { svg { display: block; width: 100%; - height: auto; + /* Fill remaining viewport height below header + search bar */ + height: calc(100vh - 120px); cursor: grab; } @@ -109,7 +112,6 @@ svg:active { position: relative; font-size: var(--font-size-xl); font-weight: bold; - top: 20px; text-align: center; color: var(--color-header); padding: var(--space-2) var(--space-4); @@ -393,10 +395,6 @@ a { padding: var(--space-2) var(--space-3); } - #header hr { - display: none; - } - #header-actions { right: var(--space-3); } @@ -417,7 +415,7 @@ a { /* Maximize SVG tree viewport */ svg { - min-height: 60vh; + height: calc(100vh - 80px); } /* Floating notes button (mobile only) */ diff --git a/public/index.html b/public/index.html index 4abff3c..9fc8790 100644 --- a/public/index.html +++ b/public/index.html @@ -31,7 +31,6 @@
-
diff --git a/public/js/arf.js b/public/js/arf.js index 7bbe86a..5919e9e 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -84,38 +84,36 @@ d3.json("arf.json").then(function(json) { root.children.forEach(collapse); - // On mobile, stretch the viewBox to match the actual rendered aspect ratio - // so the zoom-to-fill calculation uses the real visible area, not the - // fixed 1280x800 letterboxed region. - if (window.innerWidth <= 768) { - var rect = svgEl.node().getBoundingClientRect(); - if (rect.width && rect.height) { - svgH = Math.round(svgW * (rect.height / rect.width)); - svgEl.attr("viewBox", "0 0 " + svgW + " " + svgH); - } - - // Run tree layout to get final node positions, then compute zoom from data. - tree(root); - root.descendants().forEach(function(d) { d.y = d.depth * 180; }); - var visibleNodes = root.descendants(); - var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - visibleNodes.forEach(function(d) { - if (d.x < minX) minX = d.x; - if (d.x > maxX) maxX = d.x; - if (d.y < minY) minY = d.y; - if (d.y > maxY) maxY = d.y; - }); - var pad = 40; - var bw = (maxY - minY) || 1; - var bh = (maxX - minX) || 1; - var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); - var cx = (minY + maxY) / 2; - var cy = (minX + maxX) / 2; - var tx = svgW / 2 - margin[3] - cx * k; - var ty = svgH / 2 - margin[0] - cy * k; - svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); + // Stretch the viewBox to match the actual rendered aspect ratio so the + // zoom-to-fill calculation uses the real visible area, not the fixed + // 1280x800 letterboxed region. + var rect = svgEl.node().getBoundingClientRect(); + if (rect.width && rect.height) { + svgH = Math.round(svgW * (rect.height / rect.width)); + svgEl.attr("viewBox", "0 0 " + svgW + " " + svgH); } + // Run tree layout to get final node positions, then compute zoom from data. + tree(root); + root.descendants().forEach(function(d) { d.y = d.depth * 180; }); + var visibleNodes = root.descendants(); + var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + visibleNodes.forEach(function(d) { + if (d.x < minX) minX = d.x; + if (d.x > maxX) maxX = d.x; + if (d.y < minY) minY = d.y; + if (d.y > maxY) maxY = d.y; + }); + var pad = 40; + var bw = (maxY - minY) || 1; + var bh = (maxX - minX) || 1; + var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); + var cx = (minY + maxY) / 2; + var cy = (minX + maxX) / 2; + var tx = svgW / 2 - margin[3] - cx * k; + var ty = svgH / 2 - margin[0] - cy * k; + svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); + update(root); initSearch(); }); From acdf8d741b165ac573672ca0193b4d299715097b Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 20:08:17 -0400 Subject: [PATCH 15/21] Left-align tree on initial load for desktop (THE-120) Position root node near left edge of viewport so the tree has room to expand rightward when categories are opened. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- public/js/arf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/arf.js b/public/js/arf.js index 5919e9e..a30e5a2 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -110,7 +110,7 @@ d3.json("arf.json").then(function(json) { var k = Math.min((svgW - pad * 2) / bw, (svgH - pad * 2) / bh, 3); var cx = (minY + maxY) / 2; var cy = (minX + maxX) / 2; - var tx = svgW / 2 - margin[3] - cx * k; + var tx = pad - minY * k; var ty = svgH / 2 - margin[0] - cy * k; svgEl.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(k)); From 4b1510a8af9c576a7f746d8c1d1ee14379e49b7c Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 22:55:10 -0400 Subject: [PATCH 16/21] Replace thumbs up/down voting with 5-star community rating (THE-122) - HTML: Replace #panel-rating empty span with #star-rating (5 .star-btn buttons) + #rating-avg display row - CSS: Replace .vote-btn/.vote-score styles with .star-btn/.filled/ .user-rated and #rating-avg styles - JS: Replace _renderVoteUI/castVote/updateScoreDisplay with _renderVoteUI/_castRating/_applyStarFill/_updateRatingDisplay; hover highlights 1-N stars, click submits/toggles rating 1-5, sessionStorage key changed from vote: to rating: - Worker: handleVote now accepts { rating: 1-5|null } instead of { direction: "up"|"down"|null }; KV keys changed from votes:up/down to ratings:sum/count; handleStats returns ratings:{average,count} instead of votes:{up,down,score} Co-Authored-By: Paperclip --- public/css/panel.css | 46 ++++++++++--------- public/index.html | 11 ++++- public/js/arf.js | 103 ++++++++++++++++++++++++++----------------- src/worker.js | 89 +++++++++++++++++++------------------ 4 files changed, 143 insertions(+), 106 deletions(-) diff --git a/public/css/panel.css b/public/css/panel.css index 2de7f81..1db8a02 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -188,50 +188,52 @@ gap: var(--space-2); } -/* Community rating / vote */ +/* Community rating */ #panel-rating-section { display: flex; flex-direction: column; gap: var(--space-2); } -#panel-vote-row { +#panel-rating-row { display: flex; align-items: center; gap: var(--space-3); } -.vote-btn { +#star-rating { + display: flex; + gap: 2px; +} + +.star-btn { background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: var(--space-1) var(--space-2); - font-size: 18px; + border: none; + padding: 2px; + font-size: 22px; cursor: pointer; + color: var(--color-text-secondary); line-height: 1; - transition: background 150ms ease, border-color 150ms ease; + transition: color 100ms ease, transform 100ms ease; } -.vote-btn:hover { - background: var(--color-bg); - border-color: var(--color-text-secondary); +.star-btn:hover { + transform: scale(1.15); } -.vote-btn.active { - background: var(--color-accent); - border-color: var(--color-accent); +.star-btn.filled { + color: #f5a623; } -.vote-score { - font-size: var(--font-size-lg); - font-weight: bold; - min-width: 2ch; - text-align: center; +.star-btn.user-rated { + color: #e8960f; } -.vote-score.positive { color: #2d9e2d; } -.vote-score.negative { color: #c84040; } -.vote-score.zero { color: var(--color-text-secondary); } +#rating-avg { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + white-space: nowrap; +} /* Footer (sticky, always visible) */ #panel-footer { diff --git a/public/index.html b/public/index.html index 9fc8790..baaf730 100644 --- a/public/index.html +++ b/public/index.html @@ -81,7 +81,16 @@
- +
+
+ + + + + +
+ … +
diff --git a/public/js/arf.js b/public/js/arf.js index a30e5a2..7a2dca3 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -673,81 +673,102 @@ document.addEventListener("DOMContentLoaded", function() { // Canvas click: close panel when clicking the SVG background (not a node) // This is wired after svgEl is created (see below in the zoom setup area). -// === Community Voting (THE-109) === +// === Community Rating (THE-122) === /** - * Render the vote UI for the given node. - * Reads cached vote state from sessionStorage to avoid a round-trip on reopen, - * then asynchronously fetches the live score from /api/tool-stats. + * Render the 5-star rating UI for the given node. + * Reads cached rating from sessionStorage, then fetches live average from /api/tool-stats. */ function _renderVoteUI(d) { var toolId = parseName(d.data.name).cleanName; - // Reset button states - var upBtn = document.getElementById("vote-up"); - var downBtn = document.getElementById("vote-down"); - var scoreEl = document.getElementById("vote-score"); - if (!upBtn || !downBtn || !scoreEl) return; + var stars = document.querySelectorAll("#star-rating .star-btn"); + var avgEl = document.getElementById("rating-avg"); + var ratingSection = document.getElementById("panel-rating-section"); + if (!stars.length || !avgEl) return; - upBtn.classList.remove("active"); - downBtn.classList.remove("active"); - scoreEl.className = "vote-score zero"; - scoreEl.textContent = "…"; + // Reset state + _applyStarFill(stars, 0, null); + avgEl.textContent = "\u2026"; + if (ratingSection) ratingSection.classList.remove("empty"); - // Read cached user vote from sessionStorage - var userVote = sessionStorage.getItem("vote:" + toolId) || null; - if (userVote === "up") upBtn.classList.add("active"); - if (userVote === "down") downBtn.classList.add("active"); + // Read cached user rating from sessionStorage + var cached = sessionStorage.getItem("rating:" + toolId); + var userRating = cached ? parseInt(cached, 10) : null; + if (userRating) _applyStarFill(stars, userRating, userRating); - // Re-bind vote buttons for this tool - upBtn.onclick = function() { _castVote(toolId, "up", upBtn, downBtn, scoreEl); }; - downBtn.onclick = function() { _castVote(toolId, "down", upBtn, downBtn, scoreEl); }; + // Wire hover and click events for this tool + Array.prototype.forEach.call(stars, function(btn) { + var n = parseInt(btn.getAttribute("data-star"), 10); - // Fetch live score asynchronously + btn.onmouseenter = function() { _applyStarFill(stars, n, userRating); }; + btn.onmouseleave = function() { _applyStarFill(stars, userRating || 0, userRating); }; + btn.onclick = function() { + userRating = _castRating(toolId, n, userRating, stars, avgEl); + }; + }); + + // Fetch live average asynchronously fetch("/api/tool-stats?tool_id=" + encodeURIComponent(toolId)) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { - if (!data || !data.votes) return; - _updateScoreDisplay(scoreEl, data.votes.score); + if (!data || !data.ratings) return; + _updateRatingDisplay(avgEl, data.ratings.average, data.ratings.count); }) .catch(function() { /* best effort */ }); } /** - * Cast or toggle a vote. + * Apply filled/user-rated CSS classes to the star buttons. + * @param {NodeList} stars + * @param {number} fillUpTo - highlight stars 1 through fillUpTo + * @param {number|null} userRating - the user's current saved rating (shown distinctly) */ -function _castVote(toolId, direction, upBtn, downBtn, scoreEl) { - var session = sessionStorage.getItem("osint-session") || ""; - var currentVote = sessionStorage.getItem("vote:" + toolId) || null; +function _applyStarFill(stars, fillUpTo, userRating) { + Array.prototype.forEach.call(stars, function(btn) { + var n = parseInt(btn.getAttribute("data-star"), 10); + btn.classList.toggle("filled", n <= fillUpTo); + btn.classList.toggle("user-rated", userRating !== null && n <= userRating && n <= fillUpTo); + }); +} - // Toggle off if same direction - var newDirection = (direction === currentVote) ? null : direction; +/** + * Cast or toggle a star rating. + * Returns the new userRating value (number or null). + */ +function _castRating(toolId, star, currentRating, stars, avgEl) { + var session = sessionStorage.getItem("osint-session") || ""; + // Toggle off if clicking the same star + var newRating = (star === currentRating) ? null : star; fetch("/api/vote", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool_id: toolId, direction: newDirection, session_hash: session }) + body: JSON.stringify({ tool_id: toolId, rating: newRating, session_hash: session }) }) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { if (!data || !data.ok) return; - // Update sessionStorage - if (data.userVote) { - sessionStorage.setItem("vote:" + toolId, data.userVote); + var saved = data.userRating || null; + if (saved) { + sessionStorage.setItem("rating:" + toolId, String(saved)); } else { - sessionStorage.removeItem("vote:" + toolId); + sessionStorage.removeItem("rating:" + toolId); } - // Update button states - upBtn.classList.toggle("active", data.userVote === "up"); - downBtn.classList.toggle("active", data.userVote === "down"); - _updateScoreDisplay(scoreEl, data.score); + _applyStarFill(stars, saved || 0, saved); + _updateRatingDisplay(avgEl, data.average, data.count); }) .catch(function() { /* best effort */ }); + + return newRating; } -function _updateScoreDisplay(scoreEl, score) { - scoreEl.textContent = score > 0 ? "+" + score : String(score); - scoreEl.className = "vote-score " + (score > 0 ? "positive" : score < 0 ? "negative" : "zero"); +function _updateRatingDisplay(avgEl, average, count) { + if (!count || count === 0) { + avgEl.textContent = "No ratings yet"; + return; + } + avgEl.textContent = average.toFixed(1) + " (" + count + ")"; } // === Issue Reporting (THE-110) === diff --git a/src/worker.js b/src/worker.js index 0af9783..bd724ee 100644 --- a/src/worker.js +++ b/src/worker.js @@ -4,7 +4,7 @@ * API routes (all others fall through to static assets): * POST /api/track – fire-and-forget click tracking * GET /api/tool-stats – per-tool click + vote counts - * POST /api/vote – community upvote / downvote + * POST /api/vote – community 5-star rating * POST /api/report – flag dead link / paywalled / incorrect info * * KV binding: CLICK_DATA (configured in wrangler.jsonc + Pages dashboard) @@ -15,7 +15,7 @@ * - No cookies used or set * - session_hash is client-generated and ephemeral (sessionStorage) * - Rate-limit key TTL: 2 min (60 req/min ceiling per session_hash) - * - Vote dedup: permanent per session (sessionStorage already limits scope) + * - Rating dedup: permanent per session (sessionStorage already limits scope) * - Report dedup key TTL: 7 days (prevents same session from inflating counts) */ @@ -134,10 +134,10 @@ async function handleTrack(request, env) { /** * POST /api/vote - * Body: { tool_id: string, direction: "up" | "down" | null, session_hash: string } - * direction=null removes the current vote (toggle off) + * Body: { tool_id: string, rating: 1|2|3|4|5|null, session_hash: string } + * rating=null removes the current rating (toggle off) * - * Returns: { ok: true, score: number, userVote: "up" | "down" | null } + * Returns: { ok: true, average: number, count: number, userRating: number|null } */ async function handleVote(request, env) { const origin = request.headers.get("Origin") || ""; @@ -149,15 +149,15 @@ async function handleVote(request, env) { return jsonResponse({ ok: false, error: "invalid json" }, 400, origin); } - const { tool_id, direction, session_hash } = body; + const { tool_id, rating, session_hash } = body; const validationError = validateCommon(tool_id, session_hash); if (validationError) { return jsonResponse({ ok: false, error: validationError }, 400, origin); } - if (direction !== "up" && direction !== "down" && direction !== null) { + if (rating !== null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) { return jsonResponse( - { ok: false, error: "direction must be 'up', 'down', or null" }, + { ok: false, error: "rating must be an integer 1-5 or null" }, 400, origin ); @@ -167,50 +167,54 @@ async function handleVote(request, env) { return jsonResponse({ ok: false, error: "rate limited" }, 429, origin); } - const userVoteKey = `uservote:${session_hash}:${tool_id}`; - const upKey = `votes:up:${tool_id}`; - const downKey = `votes:down:${tool_id}`; + const userRatingKey = `rating:${session_hash}:${tool_id}`; + const sumKey = `ratings:sum:${tool_id}`; + const countKey = `ratings:count:${tool_id}`; // Read current state in parallel - const [prevVoteRaw, upRaw, downRaw] = await Promise.all([ - env.CLICK_DATA.get(userVoteKey), - env.CLICK_DATA.get(upKey), - env.CLICK_DATA.get(downKey), + const [prevRatingRaw, sumRaw, countRaw] = await Promise.all([ + env.CLICK_DATA.get(userRatingKey), + env.CLICK_DATA.get(sumKey), + env.CLICK_DATA.get(countKey), ]); - const prevVote = prevVoteRaw; // "up", "down", or null - let upCount = upRaw ? parseInt(upRaw, 10) : 0; - let downCount = downRaw ? parseInt(downRaw, 10) : 0; + const prevRating = prevRatingRaw ? parseInt(prevRatingRaw, 10) : null; + let sum = sumRaw ? parseInt(sumRaw, 10) : 0; + let count = countRaw ? parseInt(countRaw, 10) : 0; - // Determine the new vote: - // If same direction is sent again, treat as toggle-off (remove vote) - let newVote = direction; - if (direction !== null && direction === prevVote) { - newVote = null; // toggle off + // Toggle off if same rating submitted again + let newRating = rating; + if (rating !== null && rating === prevRating) { + newRating = null; } - // Undo previous vote - if (prevVote === "up") upCount = Math.max(0, upCount - 1); - if (prevVote === "down") downCount = Math.max(0, downCount - 1); + // Undo previous rating + if (prevRating !== null) { + sum = Math.max(0, sum - prevRating); + count = Math.max(0, count - 1); + } - // Apply new vote - if (newVote === "up") upCount++; - if (newVote === "down") downCount++; + // Apply new rating + if (newRating !== null) { + sum += newRating; + count++; + } // Persist const writes = [ - env.CLICK_DATA.put(upKey, String(upCount)), - env.CLICK_DATA.put(downKey, String(downCount)), + env.CLICK_DATA.put(sumKey, String(sum)), + env.CLICK_DATA.put(countKey, String(count)), ]; - if (newVote === null) { - writes.push(env.CLICK_DATA.delete(userVoteKey)); + if (newRating === null) { + writes.push(env.CLICK_DATA.delete(userRatingKey)); } else { - writes.push(env.CLICK_DATA.put(userVoteKey, newVote)); + writes.push(env.CLICK_DATA.put(userRatingKey, String(newRating))); } await Promise.all(writes); + const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; return jsonResponse( - { ok: true, score: upCount - downCount, userVote: newVote }, + { ok: true, average, count, userRating: newRating }, 200, origin ); @@ -339,7 +343,7 @@ async function createGitHubIssue(env, tool_id, report_type, count) { /** * GET /api/tool-stats?tool_id= * - * Returns: { tool_id: string, clicks: number, votes: { up: number, down: number, score: number } } + * Returns: { tool_id: string, clicks: number, ratings: { average: number, count: number } } */ async function handleStats(request, env) { const origin = request.headers.get("Origin") || ""; @@ -350,18 +354,19 @@ async function handleStats(request, env) { return jsonResponse({ ok: false, error: "invalid tool_id" }, 400, origin); } - const [clicksRaw, upRaw, downRaw] = await Promise.all([ + const [clicksRaw, sumRaw, countRaw] = await Promise.all([ env.CLICK_DATA.get(`clicks:${tool_id}`), - env.CLICK_DATA.get(`votes:up:${tool_id}`), - env.CLICK_DATA.get(`votes:down:${tool_id}`), + env.CLICK_DATA.get(`ratings:sum:${tool_id}`), + env.CLICK_DATA.get(`ratings:count:${tool_id}`), ]); const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0; - const up = upRaw ? parseInt(upRaw, 10) : 0; - const down = downRaw ? parseInt(downRaw, 10) : 0; + const sum = sumRaw ? parseInt(sumRaw, 10) : 0; + const count = countRaw ? parseInt(countRaw, 10) : 0; + const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; return jsonResponse( - { tool_id, clicks, votes: { up, down, score: up - down } }, + { tool_id, clicks, ratings: { average, count } }, 200, origin ); From b50c831d1e279c96b0170fa08ce60d7b9a39452c Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 23:01:21 -0400 Subject: [PATCH 17/21] Add half-star and 0-star community ratings (THE-122) Replace integer-only 1-5 stars with 0-5 scale in 0.5 increments. Each star has left/right click zones for half/full values, plus a dedicated 0-star button. Backend validation updated to accept the new range. Half-filled stars rendered via CSS linear-gradient on background-clip: text. Co-Authored-By: Paperclip --- public/css/panel.css | 84 +++++++++++++++++++++++++++++++++++++---- public/index.html | 31 ++++++++++++--- public/js/arf.js | 89 +++++++++++++++++++++++++++++--------------- src/worker.js | 21 ++++++----- 4 files changed, 175 insertions(+), 50 deletions(-) diff --git a/public/css/panel.css b/public/css/panel.css index 1db8a02..bf76e56 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -203,32 +203,102 @@ #star-rating { display: flex; + align-items: center; gap: 2px; } -.star-btn { +.star-zero-btn { background: none; - border: none; - padding: 2px; - font-size: 22px; + border: 1px solid var(--color-text-secondary); + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 11px; + font-weight: 600; cursor: pointer; color: var(--color-text-secondary); line-height: 1; + display: flex; + align-items: center; + justify-content: center; + margin-right: 2px; + transition: color 100ms ease, border-color 100ms ease, transform 100ms ease; +} + +.star-zero-btn:hover { + transform: scale(1.15); + color: #e8960f; + border-color: #e8960f; +} + +.star-zero-btn.active { + color: #e8960f; + border-color: #e8960f; + background: rgba(232, 150, 15, 0.1); +} + +.star-pos { + position: relative; + display: inline-block; + cursor: pointer; +} + +.star-icon { + font-size: 22px; + line-height: 1; + color: var(--color-text-secondary); + pointer-events: none; transition: color 100ms ease, transform 100ms ease; } -.star-btn:hover { +.star-pos:hover .star-icon { transform: scale(1.15); } -.star-btn.filled { +.star-icon.filled { color: #f5a623; } -.star-btn.user-rated { +.star-icon.half-filled { + background: linear-gradient(to right, #f5a623 50%, var(--color-text-secondary) 50%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.star-icon.user-rated { color: #e8960f; } +.star-icon.user-rated.half-filled { + background: linear-gradient(to right, #e8960f 50%, var(--color-text-secondary) 50%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.star-click { + position: absolute; + top: 0; + height: 100%; + background: none; + border: none; + cursor: pointer; + opacity: 0; + padding: 0; + margin: 0; +} + +.star-click.star-left { + left: 0; + width: 50%; +} + +.star-click.star-right { + left: 50%; + width: 50%; +} + #rating-avg { font-size: var(--font-size-sm); color: var(--color-text-secondary); diff --git a/public/index.html b/public/index.html index baaf730..c566702 100644 --- a/public/index.html +++ b/public/index.html @@ -83,11 +83,32 @@
- - - - - + + + ★ + + + + + ★ + + + + + ★ + + + + + ★ + + + + + ★ + + +
…
diff --git a/public/js/arf.js b/public/js/arf.js index 7a2dca3..4b08769 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -676,35 +676,51 @@ document.addEventListener("DOMContentLoaded", function() { // === Community Rating (THE-122) === /** - * Render the 5-star rating UI for the given node. - * Reads cached rating from sessionStorage, then fetches live average from /api/tool-stats. + * Render the half-star rating UI for the given node. + * Supports 0 to 5 in 0.5 increments. Reads cached rating from sessionStorage, + * then fetches live average from /api/tool-stats. */ function _renderVoteUI(d) { var toolId = parseName(d.data.name).cleanName; - var stars = document.querySelectorAll("#star-rating .star-btn"); + var starPositions = document.querySelectorAll("#star-rating .star-pos"); + var zeroBtn = document.querySelector("#star-rating .star-zero-btn"); var avgEl = document.getElementById("rating-avg"); var ratingSection = document.getElementById("panel-rating-section"); - if (!stars.length || !avgEl) return; + if (!starPositions.length || !avgEl) return; // Reset state - _applyStarFill(stars, 0, null); + _applyStarFill(starPositions, zeroBtn, 0, null); avgEl.textContent = "\u2026"; if (ratingSection) ratingSection.classList.remove("empty"); // Read cached user rating from sessionStorage var cached = sessionStorage.getItem("rating:" + toolId); - var userRating = cached ? parseInt(cached, 10) : null; - if (userRating) _applyStarFill(stars, userRating, userRating); + var userRating = cached !== null ? parseFloat(cached) : null; + if (userRating !== null) _applyStarFill(starPositions, zeroBtn, userRating, userRating); - // Wire hover and click events for this tool - Array.prototype.forEach.call(stars, function(btn) { - var n = parseInt(btn.getAttribute("data-star"), 10); + // Wire hover and click for zero button + if (zeroBtn) { + zeroBtn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, 0, userRating); }; + zeroBtn.onmouseleave = function() { + _applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating); + }; + zeroBtn.onclick = function() { + userRating = _castRating(toolId, 0, userRating, starPositions, zeroBtn, avgEl); + }; + } - btn.onmouseenter = function() { _applyStarFill(stars, n, userRating); }; - btn.onmouseleave = function() { _applyStarFill(stars, userRating || 0, userRating); }; + // Wire hover and click for each half-star click zone + var clickZones = document.querySelectorAll("#star-rating .star-click"); + Array.prototype.forEach.call(clickZones, function(btn) { + var val = parseFloat(btn.getAttribute("data-value")); + + btn.onmouseenter = function() { _applyStarFill(starPositions, zeroBtn, val, userRating); }; + btn.onmouseleave = function() { + _applyStarFill(starPositions, zeroBtn, userRating !== null ? userRating : -1, userRating); + }; btn.onclick = function() { - userRating = _castRating(toolId, n, userRating, stars, avgEl); + userRating = _castRating(toolId, val, userRating, starPositions, zeroBtn, avgEl); }; }); @@ -719,27 +735,41 @@ function _renderVoteUI(d) { } /** - * Apply filled/user-rated CSS classes to the star buttons. - * @param {NodeList} stars - * @param {number} fillUpTo - highlight stars 1 through fillUpTo - * @param {number|null} userRating - the user's current saved rating (shown distinctly) + * Apply filled/half-filled/user-rated classes to star icons. + * @param {NodeList} starPositions - .star-pos elements + * @param {Element|null} zeroBtn - the 0-star button + * @param {number} fillUpTo - rating value to highlight up to (0-5, supports 0.5 steps; -1 = nothing) + * @param {number|null} userRating - the user's saved rating (shown distinctly) */ -function _applyStarFill(stars, fillUpTo, userRating) { - Array.prototype.forEach.call(stars, function(btn) { - var n = parseInt(btn.getAttribute("data-star"), 10); - btn.classList.toggle("filled", n <= fillUpTo); - btn.classList.toggle("user-rated", userRating !== null && n <= userRating && n <= fillUpTo); +function _applyStarFill(starPositions, zeroBtn, fillUpTo, userRating) { + if (zeroBtn) { + zeroBtn.classList.toggle("active", userRating !== null && userRating === 0 && fillUpTo === 0); + } + Array.prototype.forEach.call(starPositions, function(pos) { + var n = parseInt(pos.getAttribute("data-star"), 10); + var icon = pos.querySelector(".star-icon"); + if (!icon) return; + var isUserStar = userRating !== null && fillUpTo === userRating; + + icon.classList.remove("filled", "half-filled", "user-rated"); + if (fillUpTo >= n) { + icon.classList.add("filled"); + if (isUserStar) icon.classList.add("user-rated"); + } else if (fillUpTo >= n - 0.5) { + icon.classList.add("half-filled"); + if (isUserStar) icon.classList.add("user-rated"); + } }); } /** - * Cast or toggle a star rating. + * Cast or toggle a rating (0-5, 0.5 increments). * Returns the new userRating value (number or null). */ -function _castRating(toolId, star, currentRating, stars, avgEl) { +function _castRating(toolId, value, currentRating, starPositions, zeroBtn, avgEl) { var session = sessionStorage.getItem("osint-session") || ""; - // Toggle off if clicking the same star - var newRating = (star === currentRating) ? null : star; + // Toggle off if clicking the same value + var newRating = (value === currentRating) ? null : value; fetch("/api/vote", { method: "POST", @@ -749,13 +779,14 @@ function _castRating(toolId, star, currentRating, stars, avgEl) { .then(function(r) { return r.ok ? r.json() : null; }) .then(function(data) { if (!data || !data.ok) return; - var saved = data.userRating || null; - if (saved) { + var saved = data.userRating; + if (saved !== null && saved !== undefined) { sessionStorage.setItem("rating:" + toolId, String(saved)); } else { + saved = null; sessionStorage.removeItem("rating:" + toolId); } - _applyStarFill(stars, saved || 0, saved); + _applyStarFill(starPositions, zeroBtn, saved !== null ? saved : -1, saved); _updateRatingDisplay(avgEl, data.average, data.count); }) .catch(function() { /* best effort */ }); diff --git a/src/worker.js b/src/worker.js index bd724ee..ac2d93a 100644 --- a/src/worker.js +++ b/src/worker.js @@ -155,12 +155,15 @@ async function handleVote(request, env) { return jsonResponse({ ok: false, error: validationError }, 400, origin); } - if (rating !== null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) { - return jsonResponse( - { ok: false, error: "rating must be an integer 1-5 or null" }, - 400, - origin - ); + if (rating !== null) { + var validRatings = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5]; + if (typeof rating !== "number" || validRatings.indexOf(rating) === -1) { + return jsonResponse( + { ok: false, error: "rating must be 0-5 in 0.5 increments, or null" }, + 400, + origin + ); + } } if (await isRateLimited(env, session_hash)) { @@ -178,8 +181,8 @@ async function handleVote(request, env) { env.CLICK_DATA.get(countKey), ]); - const prevRating = prevRatingRaw ? parseInt(prevRatingRaw, 10) : null; - let sum = sumRaw ? parseInt(sumRaw, 10) : 0; + const prevRating = prevRatingRaw ? parseFloat(prevRatingRaw) : null; + let sum = sumRaw ? parseFloat(sumRaw) : 0; let count = countRaw ? parseInt(countRaw, 10) : 0; // Toggle off if same rating submitted again @@ -361,7 +364,7 @@ async function handleStats(request, env) { ]); const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0; - const sum = sumRaw ? parseInt(sumRaw, 10) : 0; + const sum = sumRaw ? parseFloat(sumRaw) : 0; const count = countRaw ? parseInt(countRaw, 10) : 0; const average = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; From c822ae1e3c9892c825e1368c0f134b748481df8a Mon Sep 17 00:00:00 2001 From: s0lray Date: Thu, 26 Mar 2026 23:57:48 -0400 Subject: [PATCH 18/21] Add half-star and 0-star community ratings (THE-122) - HTML: Star rating uses .star-pos/.star-click split zones for half-star precision (data-value 0.5 increments); .star-zero-btn for clearing/ setting 0-star rating - CSS: .star-icon.half-filled uses CSS gradient clip for split-color; .star-click overlays are position:absolute invisible hit areas; .star-zero-btn styled as small circle badge - JS: _applyStarFill handles full/half/user-rated states; _castRating and _renderVoteUI updated to use .star-pos/.star-click/.star-zero-btn selectors and parseFloat for 0.5-step values - Worker: rating validation updated to allow 0-5 in 0.5 increments; sum stored with parseFloat to preserve decimal accuracy Co-Authored-By: Paperclip --- src/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worker.js b/src/worker.js index ac2d93a..3d7d6b7 100644 --- a/src/worker.js +++ b/src/worker.js @@ -134,7 +134,7 @@ async function handleTrack(request, env) { /** * POST /api/vote - * Body: { tool_id: string, rating: 1|2|3|4|5|null, session_hash: string } + * Body: { tool_id: string, rating: 0|0.5|1|...|5|null, session_hash: string } * rating=null removes the current rating (toggle off) * * Returns: { ok: true, average: number, count: number, userRating: number|null } From dff616f8c43d0644cd76a8844e270f3fe4b0dd1a Mon Sep 17 00:00:00 2001 From: s0lray Date: Fri, 27 Mar 2026 04:16:36 -0400 Subject: [PATCH 19/21] Enrich Online Communities metadata (THE-155) Co-Authored-By: Paperclip --- public/arf.json | 289 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 272 insertions(+), 17 deletions(-) diff --git a/public/arf.json b/public/arf.json index b8b4568..194328f 100644 --- a/public/arf.json +++ b/public/arf.json @@ -5227,12 +5227,42 @@ { "name": "Live Journal Seek", "type": "url", - "url": "https://ljseek.com/" + "url": "https://ljseek.com/", + "description": "Search tool for LiveJournal journals and communities across public entries that are indexed.", + "status": "live", + "pricing": "free", + "bestFor": "Finding public LiveJournal entries and historical community discussions", + "input": "Keywords and search terms", + "output": "Matching journal entries with author, date, and snippets", + "opsec": "passive", + "opsecNote": "Searches publicly indexed content and does not contact journal owners directly; queries are likely logged.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Blog Search Engine", "type": "url", - "url": "https://www.blogsearchengine.org/" + "url": "https://www.blogsearchengine.org/", + "description": "Blog-focused search engine for discovering blog posts, directories, and RSS-connected content.", + "status": "live", + "pricing": "free", + "bestFor": "Blog discovery and topic-focused blog post searching", + "input": "Keywords and blog topics", + "output": "Matching blog posts, listings, and feed-linked results", + "opsec": "passive", + "opsecNote": "Aggregates publicly indexed blog data without direct user contact to target blogs; queries are logged.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5243,17 +5273,62 @@ { "name": "Discord Bot List", "type": "url", - "url": "https://discord.bots.gg/" + "url": "https://discord.bots.gg/", + "description": "Searchable directory of Discord bots with listings, categories, and discovery metadata.", + "status": "live", + "pricing": "free", + "bestFor": "Discord bot discovery and ecosystem mapping", + "input": "Bot names, keywords, and categories", + "output": "Bot profiles with descriptions, ratings, and invite metadata", + "opsec": "passive", + "opsecNote": "Enumerates publicly listed bots and does not require direct Discord account interaction for browsing.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "ReconXplorer (T)", "type": "url", - "url": "https://github.com/root7am/ReconXplorer" + "url": "https://github.com/root7am/ReconXplorer", + "description": "Open-source reconnaissance toolkit with modules for IP, email, and Discord-focused lookups.", + "status": "live", + "pricing": "free", + "bestFor": "Multi-input OSINT checks from a local scriptable toolkit", + "input": "IP addresses, emails, Discord tokens, and host data", + "output": "Recon results including geolocation, service, and account-related metadata", + "opsec": "active", + "opsecNote": "Runs direct external queries from the operator environment and may trigger service-side detection or logging.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Top.gg", "type": "url", - "url": "https://top.gg/" + "url": "https://top.gg/", + "description": "Large Discord bot and app discovery platform with ranking, filtering, and listing data.", + "status": "live", + "pricing": "free", + "bestFor": "Discord bot ranking analysis and app discovery", + "input": "Bot names, tags, and search filters", + "output": "Bot listings with popularity, server counts, and profile details", + "opsec": "passive", + "opsecNote": "Uses public listing data and does not require direct target interaction for standard browsing.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5264,27 +5339,102 @@ { "name": "BoardReader", "type": "url", - "url": "https://boardreader.com/" + "url": "https://boardreader.com/", + "description": "Forum search engine that indexes discussions across message boards and community platforms.", + "status": "live", + "pricing": "free", + "bestFor": "Finding forum threads and topic-centric discussion history", + "input": "Keywords, forum names, and topical queries", + "output": "Indexed posts and thread references with source links", + "opsec": "passive", + "opsecNote": "Searches indexed forum content and avoids direct interaction with target forum users.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Omgili", "type": "url", - "url": "https://webz.io/" + "url": "https://webz.io/", + "description": "Forum and discussion search capability operated through Webz.io infrastructure and data products.", + "status": "live", + "pricing": "freemium", + "bestFor": "Forum discussion discovery with optional API-driven workflows", + "input": "Keywords and Boolean-style forum queries", + "output": "Discussion posts and thread-level matching results", + "opsec": "passive", + "opsecNote": "Uses indexed discussion datasets from a third-party provider; user-side browsing is passive.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Craigslist Forums", "type": "url", - "url": "https://forums.craigslist.org/" + "url": "https://forums.craigslist.org/", + "description": "Craigslist-hosted forum system for public community discussions and region-oriented threads.", + "status": "live", + "pricing": "free", + "bestFor": "Reviewing Craigslist community discussions and regional forum activity", + "input": "Forum categories, keywords, and regional navigation", + "output": "Forum threads, post content, and timing context", + "opsec": "passive", + "opsecNote": "Reads publicly visible forum content and does not require direct messaging or contact with users.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Delphi Forum Search", "type": "url", - "url": "https://www.delphiforums.com/" + "url": "https://www.delphiforums.com/", + "description": "Forum platform with searchable user communities across niche interest categories.", + "status": "live", + "pricing": "freemium", + "bestFor": "Niche forum discovery and historical community thread review", + "input": "Forum names, categories, and keywords", + "output": "Forum listings, thread titles, and message pages", + "opsec": "passive", + "opsecNote": "Public forum browsing is passive; some communities may require registration for deeper access.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Google Groups Search", "type": "url", - "url": "https://groups.google.com/forum/#!overview" + "url": "https://groups.google.com/forum/#!overview", + "description": "Search interface for Google Groups and archived discussion content, including historical threads.", + "status": "live", + "pricing": "free", + "bestFor": "Researching archived mailing-list and discussion-group content", + "input": "Keywords, group names, authors, and date ranges", + "output": "Thread messages and group-level discussion results", + "opsec": "passive", + "opsecNote": "Searches archived public discussions through Google-hosted infrastructure where activity is logged.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": true, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5295,22 +5445,82 @@ { "name": "Mibbit", "type": "url", - "url": "https://search.mibbit.com/" + "url": "https://search.mibbit.com/", + "description": "Former web IRC client and channel search service that is no longer operational.", + "status": "down", + "pricing": "free", + "bestFor": "Legacy reference for historical IRC channel search workflows", + "input": "Channel or keyword queries (historical behavior)", + "output": "No current output; service is discontinued", + "opsec": "Unknown", + "opsecNote": "Service shut down on August 30, 2024 and cannot be relied on for active workflows.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": true }, { "name": "IRCP (T)", "type": "url", - "url": "https://github.com/internet-relay-chat/IRCP" + "url": "https://github.com/internet-relay-chat/IRCP", + "description": "Python-based IRC probing utility for scanning servers and collecting network/channel metadata.", + "status": "live", + "pricing": "free", + "bestFor": "IRC server enumeration and protocol-level reconnaissance", + "input": "Target ranges, IRC ports, and server parameters", + "output": "Server and channel metadata in machine-readable output", + "opsec": "active", + "opsecNote": "Actively connects to IRC services and may be detectable by network monitoring and server logs.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "ircsnapshot (T)", "type": "url", - "url": "https://github.com/bwall/ircsnapshot" + "url": "https://github.com/bwall/ircsnapshot", + "description": "IRC data collection tool that connects bots to servers for user and channel mapping.", + "status": "live", + "pricing": "free", + "bestFor": "IRC topology mapping and user/channel relationship analysis", + "input": "IRC server details, bot config, and channel targets", + "output": "Collected user, hostmask, and channel affiliation data", + "opsec": "active", + "opsecNote": "Requires active network participation via bot accounts and is likely visible to channel operators.", + "localInstall": true, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "netsplit.de", "type": "url", - "url": "https://netsplit.de/channels/search.php" + "url": "https://netsplit.de/channels/search.php", + "description": "IRC directory and search portal for channel listings, network stats, and discovery workflows.", + "status": "live", + "pricing": "free", + "bestFor": "Passive IRC channel discovery and network trend checks", + "input": "Channel names, keywords, and network filters", + "output": "Channel listings with network and user-count context", + "opsec": "passive", + "opsecNote": "Uses aggregated indexed IRC data and does not require direct IRC server interaction for searches.", + "localInstall": false, + "googleDork": true, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] }, @@ -5321,17 +5531,62 @@ { "name": "Arctic Shift", "type": "url", - "url": "https://arctic-shift.photon-reddit.com/" + "url": "https://arctic-shift.photon-reddit.com/", + "description": "Search and access layer for Reddit datasets with tools for historical content retrieval and analysis.", + "status": "live", + "pricing": "freemium", + "bestFor": "Historical Reddit dataset analysis and subreddit research", + "input": "Search terms, dataset queries, or API-style requests", + "output": "Matching Reddit posts, comments, and metadata from indexed datasets", + "opsec": "passive", + "opsecNote": "Works against archived or indexed data sources without requiring direct engagement with target accounts.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": true, + "invitationOnly": false, + "deprecated": false }, { "name": "Cama's Reddit Search", "type": "url", - "url": "https://camas.github.io/reddit-search/" + "url": "https://camas.github.io/reddit-search/", + "description": "Web tool for searching Reddit posts and comments by author, subreddit, text, and time filters.", + "status": "live", + "pricing": "free", + "bestFor": "Reddit user and subreddit content discovery", + "input": "Usernames, subreddits, keywords, and date constraints", + "output": "Filtered post/comment search results with metadata", + "opsec": "passive", + "opsecNote": "Uses public data indexing paths; original GitHub hosting was discontinued but alternative hosting remains available.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false }, { "name": "Reveddit", "type": "url", - "url": "https://www.reveddit.com/" + "url": "https://www.reveddit.com/", + "description": "Interface for viewing Reddit content removals using archived and moderation-related visibility signals.", + "status": "live", + "pricing": "free", + "bestFor": "Investigating deleted or removed Reddit discussions", + "input": "Reddit URLs, usernames, or subreddit paths", + "output": "Recovered or flagged removed-content views with moderation indicators", + "opsec": "passive", + "opsecNote": "Queries archive-backed sources and public content pathways without direct contact to target users.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": true, + "api": false, + "invitationOnly": false, + "deprecated": false } ] } From 6f21a88ffe9f8f6964930fb3b38e612dcc0190e2 Mon Sep 17 00:00:00 2001 From: s0lray Date: Fri, 27 Mar 2026 08:30:59 -0400 Subject: [PATCH 20/21] Fix mobile detail panel height to account for browser chrome (THE-188) Use 100dvh (dynamic viewport height) instead of 100vh so the panel footer with "Report an Issue" buttons is accessible on mobile browsers where the address bar and navigation bar reduce visible viewport height. Falls back to 100vh for older browsers. Also enable pointer-events on the panel overlay on mobile so tapping outside the panel closes it. Co-Authored-By: Paperclip --- public/css/panel.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/css/panel.css b/public/css/panel.css index bf76e56..f623f76 100644 --- a/public/css/panel.css +++ b/public/css/panel.css @@ -21,6 +21,7 @@ right: 0; width: 340px; height: 100vh; + height: 100dvh; background: var(--color-surface); border-left: 1px solid var(--color-border); box-shadow: var(--shadow-md); @@ -458,9 +459,14 @@ #tool-panel { width: 100vw; height: 100vh; + height: 100dvh; top: 0; right: 0; border-left: none; border-radius: 0; } + + #panel-overlay.visible { + pointer-events: auto; + } } From 39ebb0ac7e8200d9e5fdcfdc369f231906d3fdf9 Mon Sep 17 00:00:00 2001 From: s0lray Date: Fri, 27 Mar 2026 21:59:18 -0400 Subject: [PATCH 21/21] Update arf.json --- public/arf.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/public/arf.json b/public/arf.json index 194328f..17beb49 100644 --- a/public/arf.json +++ b/public/arf.json @@ -10,6 +10,26 @@ "name": "Username Search Engines", "type": "folder", "children": [ + { + "name": "WhatsMyName (T)", + "type": "url", + "url": "https://github.com/WebBreacher/WhatsMyName", + "description": "OSINT project maintaining a curated JSON database of website detection patterns for username enumeration. Web interface available at whatsmyname.app.", + "status": "live", + "pricing": "free", + "bestFor": "Username enumeration using community-maintained site detection data", + "input": "Username", + "output": "List of sites where the username exists, based on HTTP response pattern matching", + "opsec": "active", + "opsecNote": "Makes HTTP requests to each target site to check for username existence.", + "localInstall": false, + "googleDork": false, + "registration": false, + "editUrl": false, + "api": false, + "invitationOnly": false, + "deprecated": false + }, { "name": "Sylva Identity Discovery (T)", "type": "url", @@ -70,26 +90,6 @@ "invitationOnly": false, "deprecated": false }, - { - "name": "WhatsMyName (T)", - "type": "url", - "url": "https://github.com/WebBreacher/WhatsMyName", - "description": "OSINT project maintaining a curated JSON database of website detection patterns for username enumeration. Web interface available at whatsmyname.app.", - "status": "live", - "pricing": "free", - "bestFor": "Username enumeration using community-maintained site detection data", - "input": "Username", - "output": "List of sites where the username exists, based on HTTP response pattern matching", - "opsec": "active", - "opsecNote": "Makes HTTP requests to each target site to check for username existence.", - "localInstall": false, - "googleDork": false, - "registration": false, - "editUrl": false, - "api": false, - "invitationOnly": false, - "deprecated": false - }, { "name": "Thats Them", "type": "url",