Unlocks Paywalls in recon
// ==UserScript==
// @name Recon+
// @namespace https://update.greasyfork.org/scripts/582087/Recon%2B.user.js
// @description Unlocks Paywalls in recon
// @version 1.9
// @license MIT
// @match https://www.recon.com/*
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const profileDetailsCache = new Map();
let lastBlockedRequest = null;
let lastProfileDetailPageData = null;
const authHeaders = {};
// Complete dictionary for interest IDs
const interestMap = {
1: "Recon Men", 2: "Skinheads", 4: "Leather", 5: "Rubber", 6: "Sports Gear",
7: "Military", 8: "Hoods & Masks", 9: "Muscle", 10: "Punks", 13: "Bikers",
15: "Bondage", 18: "Suits", 19: "Fisting", 20: "Masters & Slaves", 27: "Boots",
30: "Tattoos & Piercings", 31: "Bears", 34: "Fighting", 36: "Feet",
37: "Pups & Handlers", 38: "Smokers", 39: "Gunge", 40: "Trackies",
41: "Underwear", 42: "Chastity", 43: "Watersports", 44: "Impact Play",
45: "Electro", 46: "Sneakers & Socks", 47: "ADBL"
};
// -------------------------------------------------------------------------
// 1. CSS INJECTION (Hiding Angular's original elements)
// -------------------------------------------------------------------------
const style = document.createElement('style');
style.innerHTML = `
.cdk-overlay-container:has(t101-paywall), t101-paywall { display: none !important; opacity: 0 !important; pointer-events: none !important; }
html.cdk-global-scrollblock, body { position: static !important; overflow-y: auto !important; overflow: auto !important; width: auto !important; }
.photo-tile { pointer-events: auto !important; cursor: pointer !important; }
.text-container .interests-container { display: none !important; }
.text-container { height: auto !important; max-height: none !important; padding-bottom: 15px !important; }
`;
if (document.head) { document.head.appendChild(style); } else {
const docObserver = new MutationObserver(() => { if (document.head) { document.head.appendChild(style); docObserver.disconnect(); } });
docObserver.observe(document.documentElement, { childList: true });
}
// -------------------------------------------------------------------------
// 2. TOAST SYSTEM
// -------------------------------------------------------------------------
function showToast(message, type = 'success') {
let container = document.getElementById('custom-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'custom-toast-container';
container.style.cssText = `position: fixed; top: 20px; right: 20px; z-index: 99999; display: flex; flex-direction: column; gap: 10px; pointer-events: none;`;
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `background: ${type === 'error' ? '#cf2a2a' : '#2acf65'}; color: #fff; padding: 12px 24px; border-radius: 4px; font-family: sans-serif; font-weight: bold; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transform: translateY(-20px); transition: all 0.3s ease;`;
container.appendChild(toast);
setTimeout(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }, 10);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-20px)'; setTimeout(() => toast.remove(), 300); }, 3500);
}
// -------------------------------------------------------------------------
// 3. XHR INTERCEPTOR
// -------------------------------------------------------------------------
const origXHR = win.XMLHttpRequest;
win.XMLHttpRequest = function () {
const xhr = new origXHR();
const origOpen = xhr.open;
const origSend = xhr.send;
const origSetHeader = xhr.setRequestHeader;
xhr._headers = {};
xhr.setRequestHeader = function (header, value) {
this._headers[header] = value;
return origSetHeader.apply(this, arguments);
};
xhr.open = function (method, url) {
this._customUrl = url;
if (typeof url === 'string' && url.includes('/visitedProfiles/')) {
if (!this._isManualTrigger) {
this.send = function (body) {
lastBlockedRequest = {
url: url,
method: method,
headers: this._headers,
body: body
};
Object.defineProperty(this, 'readyState', { value: 4 });
Object.defineProperty(this, 'status', { value: 200 });
Object.defineProperty(this, 'responseText', { value: '{"success":true}' });
if (this.onreadystatechange) this.onreadystatechange();
if (this.onload) this.onload();
};
return origOpen.apply(this, arguments);
}
}
return origOpen.apply(this, arguments);
};
xhr.send = function () {
if (this._headers) {
const authKeys = ['authorization', 'x-xsrf-token', 'client-id', 'x-client-id'];
for (const key of Object.keys(this._headers)) {
if (authKeys.includes(key.toLowerCase())) {
authHeaders[key] = this._headers[key];
}
}
}
this.addEventListener('load', function () {
if (this._customUrl && this._customUrl.includes('/api/profile/profiles/')) {
try {
const data = JSON.parse(this.responseText);
if (data && data.id) {
if (this._customUrl.includes('/detail/')) {
lastProfileDetailPageData = data;
const existing = document.getElementById('custom-profile-last-updated');
if (existing) existing.remove();
setTimeout(() => paintProfileDetailPage(data), 50);
saveProfileToIndexedDB(data.id, data);
} else {
profileDetailsCache.set(data.id, data);
setTimeout(() => paintProfileData(data.id), 50);
}
}
} catch (e) { console.error("[Script] Error parsing profile data", e); }
}
});
return origSend.apply(this, arguments);
};
return xhr;
};
// Helper to query location from IndexedDB (RECON_DB -> App_Locations)
function getLocationFromIndexedDB(locationId) {
return new Promise((resolve) => {
try {
const request = win.indexedDB.open("RECON_DB");
request.onsuccess = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains("App_Locations")) {
db.close();
resolve(null);
return;
}
const transaction = db.transaction("App_Locations", "readonly");
const store = transaction.objectStore("App_Locations");
function checkVal(val) {
if (val && val.data) {
if (val.expiry) {
const expiryDate = new Date(val.expiry);
if (expiryDate > new Date()) {
return val.data;
}
} else {
return val.data;
}
}
return null;
}
// Try querying as number first
const getRequest = store.get(Number(locationId));
getRequest.onsuccess = function () {
let result = checkVal(getRequest.result);
if (result) {
db.close();
resolve(result);
} else {
// Try querying as string
const getRequestStr = store.get(String(locationId));
getRequestStr.onsuccess = function () {
db.close();
resolve(checkVal(getRequestStr.result));
};
getRequestStr.onerror = function () {
db.close();
resolve(null);
};
}
};
getRequest.onerror = function () {
db.close();
resolve(null);
};
};
request.onerror = function () {
resolve(null);
};
} catch (e) {
resolve(null);
}
});
}
// Helper to save location to IndexedDB (RECON_DB -> App_Locations)
function saveLocationToIndexedDB(locationId, locationUrl, name, longName) {
try {
const request = win.indexedDB.open("RECON_DB");
request.onsuccess = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains("App_Locations")) {
db.close();
return;
}
const transaction = db.transaction("App_Locations", "readwrite");
const store = transaction.objectStore("App_Locations");
const expiryDate = new Date();
expiryDate.setFullYear(expiryDate.getFullYear() + 1); // 1 year expiry
const value = {
data: {
locationId: Number(locationId),
locationUrl: locationUrl,
longName: longName,
name: name
},
expiry: expiryDate.toString()
};
store.put(value, Number(locationId));
transaction.oncomplete = function () {
db.close();
};
};
} catch (e) {
console.error("[Script] Error saving location to IndexedDB", e);
}
}
// Helper to query profile from IndexedDB (RECON_DB -> Profile_Cache)
function getProfileFromIndexedDB(profileId) {
return new Promise((resolve) => {
try {
const request = win.indexedDB.open("RECON_DB");
request.onsuccess = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains("Profile_Cache")) {
db.close();
resolve(null);
return;
}
const transaction = db.transaction("Profile_Cache", "readonly");
const store = transaction.objectStore("Profile_Cache");
function checkVal(val) {
if (val) {
if (val.data) {
if (val.expiry) {
const expiryDate = new Date(val.expiry);
if (expiryDate > new Date()) {
return val.data;
}
} else {
return val.data;
}
}
return val;
}
return null;
}
const getRequest = store.get(Number(profileId));
getRequest.onsuccess = function () {
let result = checkVal(getRequest.result);
if (result) {
db.close();
resolve(result);
} else {
const getRequestStr = store.get(String(profileId));
getRequestStr.onsuccess = function () {
db.close();
resolve(checkVal(getRequestStr.result));
};
getRequestStr.onerror = function () {
db.close();
resolve(null);
};
}
};
getRequest.onerror = function () {
db.close();
resolve(null);
};
};
request.onerror = function () {
resolve(null);
};
} catch (e) {
resolve(null);
}
});
}
// Helper to save profile to IndexedDB (RECON_DB -> Profile_Cache)
function saveProfileToIndexedDB(profileId, data) {
try {
const request = win.indexedDB.open("RECON_DB");
request.onsuccess = function (event) {
const db = event.target.result;
if (!db.objectStoreNames.contains("Profile_Cache")) {
db.close();
return;
}
const transaction = db.transaction("Profile_Cache", "readwrite");
const store = transaction.objectStore("Profile_Cache");
const expiryDate = new Date();
expiryDate.setFullYear(expiryDate.getFullYear() + 1); // 1 year expiry
const value = {
data: data,
expiry: expiryDate.toString()
};
store.put(value, Number(profileId));
transaction.oncomplete = function () {
db.close();
};
};
} catch (e) {
console.error("[Script] Error saving profile to IndexedDB", e);
}
}
function checkAndPaintProfileFromIndexedDB() {
const match = win.location.pathname.match(/\/members\/([^/]+)/);
if (match) {
const profileId = match[1];
if (profileId && !isNaN(Number(profileId))) {
getProfileFromIndexedDB(profileId).then(cachedProfile => {
if (cachedProfile) {
lastProfileDetailPageData = cachedProfile;
paintProfileDetailPage(cachedProfile);
}
});
}
}
}
// -------------------------------------------------------------------------
// 4. DOM INJECTION ENGINE
// -------------------------------------------------------------------------
function paintProfileData(profileId) {
const card = document.getElementById(profileId);
if (!card || card.dataset.reconInjected) return;
const data = profileDetailsCache.get(profileId);
const textContainer = card.querySelector('.text-container');
if (!data || !textContainer) return;
card.dataset.reconInjected = "true";
// A. Inject Location (Using IndexedDB with Fetch fallback)
if (data.location && data.location.locationId) {
let locDiv = document.createElement('div');
locDiv.className = 'custom-injected-location';
locDiv.style.cssText = `font-family: 'Raleway', sans-serif; font-size: 13px; font-weight: 700; color: #e32222; margin: 4px 0; text-align: left;`;
locDiv.innerHTML = `📍 Loading...`;
const nameNode = textContainer.querySelector('.name');
if (nameNode) nameNode.after(locDiv); else textContainer.prepend(locDiv);
const rawLocId = data.location.locationId;
if (isNaN(Number(rawLocId))) {
locDiv.innerHTML = `📍 ${rawLocId}`;
} else {
getLocationFromIndexedDB(rawLocId).then(cachedLoc => {
if (cachedLoc) {
const locName = cachedLoc.name || cachedLoc.longName || rawLocId;
locDiv.innerHTML = `📍 ${locName}`;
} else {
let fetchUrl = data.location.locationUrl || `https://www.recon.com/api/location/locations/${rawLocId}`;
if (!fetchUrl.includes('culture=')) {
fetchUrl += (fetchUrl.includes('?') ? '&' : '?') + 'culture=en';
}
win.fetch(fetchUrl, {
headers: {
...authHeaders
}
})
.then(res => res.json())
.then(locData => {
const name = locData.shortLocationName || locData.name || locData.longLocationName;
if (name) {
const longName = locData.longLocationName || locData.longName || name;
locDiv.innerHTML = `📍 ${name}`;
saveLocationToIndexedDB(rawLocId, fetchUrl, name, longName);
} else {
locDiv.innerHTML = `📍 Loc ID: ${rawLocId}`;
}
}).catch(() => { locDiv.innerHTML = `📍 Loc ID: ${rawLocId}`; });
}
});
}
}
// B. Inject Full Interests Array
if (data.interests && data.interests.length > 0) {
let intDiv = document.createElement('div');
intDiv.className = 'custom-injected-interests';
intDiv.style.cssText = `display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; justify-content: flex-start;`;
data.interests.forEach(id => {
let span = document.createElement('span');
span.style.cssText = `background-color: rgba(255, 255, 255, 0.2); color: #fff; padding: 3px 8px; border-radius: 10px; font-size: 11px; font-weight: bold;`;
span.textContent = interestMap[id] || `Tag: ${id}`;
intDiv.appendChild(span);
});
textContainer.appendChild(intDiv);
}
}
function paintProfileDetailPage(data) {
if (!data || !data.lastUpdatedDate) return;
let container = document.querySelector('.profile-header, t101-profile-header, .member-info, .profile-info-container');
if (!container) {
container = document.querySelector('h1');
}
if (!container) {
const cruiseBtn = document.querySelector('a[href*="/cruise"], button:has(.fa-ship), [class*="cruise"], button:has(t101-icon-cruise), button:has(t101-icon-cruise-icon), t101-icon-cruise, t101-icon-cruise-icon, button:has([class*="cruise"]), button:has([id*="cruise"])');
if (cruiseBtn) {
container = cruiseBtn.parentElement;
}
}
if (!container) return;
if (document.getElementById('custom-profile-last-updated')) return;
const dateDiv = document.createElement('div');
dateDiv.id = 'custom-profile-last-updated';
dateDiv.style.cssText = `
font-family: 'Raleway', sans-serif;
font-size: 14px;
font-weight: 700;
color: #adadad;
margin: 10px 0;
display: inline-flex;
align-items: center;
gap: 6px;
`;
try {
const dateObj = new Date(data.lastUpdatedDate);
const formattedDate = dateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
dateDiv.innerHTML = `📅 Profile Updated: <span style="color: #fff; font-weight: 800;">${formattedDate}</span>`;
} catch (e) {
dateDiv.innerHTML = `📅 Profile Updated: <span style="color: #fff; font-weight: 800;">${data.lastUpdatedDate}</span>`;
}
if (container.tagName === 'H1') {
container.after(dateDiv);
} else {
container.appendChild(dateDiv);
}
}
// -------------------------------------------------------------------------
// 5. RESTORED FEATURES (Buttons & Lightbox)
// -------------------------------------------------------------------------
async function fetchImageAsBlob(url) {
try {
const res = await win.fetch(url);
if (res.ok) return await res.blob();
} catch (e) {
console.warn(`[Script] Standard fetch failed for ${url}, trying GM_xmlhttpRequest:`, e);
}
if (typeof GM_xmlhttpRequest !== 'undefined') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
resolve(response.response);
} else {
reject(new Error(`GM_xmlhttpRequest failed with status ${response.status}`));
}
},
onerror: function (err) {
reject(err);
}
});
});
}
throw new Error(`Cannot fetch ${url} without CORS or GM_xmlhttpRequest`);
}
function getJSZip() {
if (typeof JSZip !== 'undefined') return Promise.resolve(JSZip);
if (win.JSZip) return Promise.resolve(win.JSZip);
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js';
script.onload = () => resolve(window.JSZip || win.JSZip);
script.onerror = () => reject(new Error('Failed to load JSZip'));
document.head.appendChild(script);
});
}
async function exportProfileData() {
showToast('Preparing profile export...', 'success');
let JSZipLib;
try {
JSZipLib = await getJSZip();
} catch (e) {
console.error(e);
showToast('Failed to load JSZip library.', 'error');
return;
}
const data = lastProfileDetailPageData;
const profileId = data ? data.id : (win.location.pathname.match(/\/members\/([^/]+)/) || [])[1] || 'unknown';
const displayName = data ? (data.displayName || data.username) : (document.title || 'Profile');
// 1. Gather all unique photo URLs
const photoUrls = new Set();
// From DOM photo-tiles
document.querySelectorAll('.photo-tile').forEach(t => {
const bgImg = t.style.backgroundImage;
if (bgImg) {
const url = bgImg.replace(/^url\(["']?/, '').replace(/["']?\)$/, '').split('?')[0];
if (url) photoUrls.add(url);
}
});
// From DOM profile avatars
document.querySelectorAll('.profile-avatar').forEach(t => {
const bgImg = t.style.backgroundImage;
if (bgImg) {
const url = bgImg.replace(/^url\(["']?/, '').replace(/["']?\)$/, '').split('?')[0];
if (url) photoUrls.add(url);
}
});
// From lastProfileDetailPageData recursively
if (data) {
const findUrls = (obj) => {
if (typeof obj === 'string') {
if (obj.startsWith('http') && (obj.includes('/photos/') || obj.includes('/images/') || obj.match(/\.(jpg|jpeg|png|webp|gif)/i))) {
photoUrls.add(obj.split('?')[0]);
}
} else if (typeof obj === 'object' && obj !== null) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
findUrls(obj[key]);
}
}
}
};
findUrls(data);
}
const urlsArray = Array.from(photoUrls);
if (urlsArray.length === 0) {
showToast('No photos found to export.', 'error');
return;
}
showToast(`Found ${urlsArray.length} photos. Starting download...`, 'success');
const zip = new JSZipLib();
// 2. Add profile_info.txt
let infoText = '';
infoText += `=== RECON PROFILE EXPORT ===\n`;
infoText += `Export Date: ${new Date().toLocaleString()}\n`;
infoText += `Profile URL: ${win.location.href}\n\n`;
if (data) {
infoText += `Display Name: ${data.displayName || 'N/A'}\n`;
infoText += `Username: ${data.username || 'N/A'}\n`;
infoText += `Profile ID: ${data.id || 'N/A'}\n`;
if (data.location) {
infoText += `Location: ID ${data.location.locationId || 'N/A'}\n`;
}
if (data.lastUpdatedDate) {
infoText += `Last Updated: ${data.lastUpdatedDate}\n`;
}
if (data.interests && data.interests.length > 0) {
infoText += `Interests: ${data.interests.map(id => interestMap[id] || `Tag ${id}`).join(', ')}\n`;
}
infoText += `\n--- PROFILE INFO (RAW KEY-VALUES) ---\n`;
for (const [key, val] of Object.entries(data)) {
if (typeof val !== 'object' && val !== null) {
infoText += `${key}: ${val}\n`;
}
}
}
// Gather bio and details text from DOM
const bioSection = document.querySelector('.text-container, .bio-container, .member-bio');
if (bioSection) {
infoText += `\n--- PROFILE BIO ---\n`;
infoText += bioSection.innerText.trim() + `\n`;
}
const infoSections = document.querySelectorAll('.details-container, .stats-container, .profile-details');
if (infoSections.length > 0) {
infoText += `\n--- PROFILE DETAILS ---\n`;
infoSections.forEach(section => {
infoText += section.innerText.trim() + `\n\n`;
});
}
zip.file("profile_info.txt", infoText);
// 3. Add raw JSON if present
if (data) {
zip.file("profile_data.json", JSON.stringify(data, null, 4));
}
// 4. Download and add photos
let successCount = 0;
const photosFolder = zip.folder("photos");
const downloadPromises = urlsArray.map(async (url, idx) => {
try {
const blob = await fetchImageAsBlob(url);
const ext = url.split('.').pop().split(/[?#]/)[0] || 'jpg';
photosFolder.file(`photo_${idx + 1}.${ext}`, blob);
successCount++;
} catch (err) {
console.error(`[Script] Failed to download photo from ${url}:`, err);
}
});
await Promise.all(downloadPromises);
if (successCount === 0) {
showToast('Failed to download any profile photos.', 'error');
return;
}
showToast(`Zipping ${successCount} photos...`, 'success');
try {
const content = await zip.generateAsync({ type: 'blob' });
const blobUrl = URL.createObjectURL(content);
const a = document.createElement('a');
const safeName = displayName.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
a.href = blobUrl;
a.download = `${safeName}_${profileId}_export.zip`;
a.click();
URL.revokeObjectURL(blobUrl);
showToast(`Export complete! Saved ${successCount}/${urlsArray.length} photos.`, 'success');
} catch (err) {
console.error('[Script] Error creating zip file:', err);
showToast('Error generating zip archive.', 'error');
}
}
function injectViewedButton() {
const cruiseBtn = document.querySelector('a[href*="/cruise"], button:has(.fa-ship), [class*="cruise"], button:has(t101-icon-cruise), button:has(t101-icon-cruise-icon), t101-icon-cruise, t101-icon-cruise-icon, button:has([class*="cruise"]), button:has([id*="cruise"])');
if (cruiseBtn) {
const targetEl = cruiseBtn.closest('button') || cruiseBtn.closest('a') || cruiseBtn;
// Inject Viewed Button
let viewBtn = document.getElementById('manual-view-btn');
if (!viewBtn) {
viewBtn = document.createElement('button');
viewBtn.id = 'manual-view-btn';
viewBtn.textContent = 'Show As Viewed';
viewBtn.style.cssText = `background-color: #2b2b2b; color: #ffffff; border: 1px solid #444; padding: 8px 16px; margin-left: 8px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 14px; transition: background 0.2s;`;
viewBtn.onmouseover = () => viewBtn.style.backgroundColor = '#3d3d3d';
viewBtn.onmouseout = () => viewBtn.style.backgroundColor = '#2b2b2b';
viewBtn.addEventListener('click', () => {
if (!lastBlockedRequest) return showToast('No profile visit tracker captured yet.', 'error');
const xhr = new origXHR();
xhr._isManualTrigger = true;
xhr.open(lastBlockedRequest.method, lastBlockedRequest.url);
if (lastBlockedRequest.headers) {
for (const [key, value] of Object.entries(lastBlockedRequest.headers)) {
xhr.setRequestHeader(key, value);
}
}
xhr.onload = function () {
if (this.status >= 200 && this.status < 300) {
showToast('Profile marked as viewed successfully!');
} else {
let errorMsg = `Failed to contact server: Status ${this.status}`;
try {
const errData = JSON.parse(this.responseText);
if (errData && errData.title) {
errorMsg = errData.title;
}
} catch (e) { }
showToast(errorMsg, 'error');
}
};
xhr.onerror = () => showToast('Failed to contact server.', 'error');
xhr.send(lastBlockedRequest.body);
});
targetEl.parentNode.insertBefore(viewBtn, targetEl.nextSibling);
}
// Inject Export Button
if (!document.getElementById('custom-export-profile-btn')) {
const exportBtn = document.createElement('button');
exportBtn.id = 'custom-export-profile-btn';
exportBtn.textContent = 'Export Profile';
exportBtn.style.cssText = `background-color: #2b2b2b; color: #ffffff; border: 1px solid #444; padding: 8px 16px; margin-left: 8px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 14px; transition: background 0.2s;`;
exportBtn.onmouseover = () => exportBtn.style.backgroundColor = '#3d3d3d';
exportBtn.onmouseout = () => exportBtn.style.backgroundColor = '#2b2b2b';
exportBtn.addEventListener('click', exportProfileData);
viewBtn.parentNode.insertBefore(exportBtn, viewBtn.nextSibling);
}
}
}
function setupPhotoViewer() {
const tiles = document.querySelectorAll('.photo-tile:not([data-lightbox-ready])');
tiles.forEach(tile => {
tile.setAttribute('data-lightbox-ready', 'true');
tile.style.pointerEvents = 'auto'; tile.style.cursor = 'pointer';
tile.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
// Get all photo tiles in the same gallery container, or globally on the page if not grouped
const container = tile.closest('.member-photos, t101-profile-photos, .gallery-grid, .photo-grid') || document.body;
const allTiles = Array.from(container.querySelectorAll('.photo-tile'));
const urls = allTiles.map(t => {
const bgImg = t.style.backgroundImage;
return bgImg ? bgImg.replace(/^url\(["']?/, '').replace(/["']?\)$/, '').split('?')[0] : null;
}).filter(Boolean);
const bgImg = tile.style.backgroundImage;
if (!bgImg) return;
let currentUrl = bgImg.replace(/^url\(["']?/, '').replace(/["']?\)$/, '').split('?')[0];
let currentIndex = urls.indexOf(currentUrl);
if (currentIndex === -1) {
currentIndex = 0;
urls.unshift(currentUrl);
}
createFullscreenLightbox(urls, currentIndex);
});
});
}
function createFullscreenLightbox(urls, startIndex) {
if (document.getElementById('custom-photo-lightbox')) return;
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
let currentIndex = startIndex;
const overlay = document.createElement('div');
overlay.id = 'custom-photo-lightbox';
overlay.style.cssText = `position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.95); z-index: 100000; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.25s ease;`;
const img = document.createElement('img');
img.src = urls[currentIndex];
img.style.cssText = `max-width: 90%; max-height: 90%; object-fit: contain; box-shadow: 0 0 20px rgba(0,0,0,0.8); border-radius: 4px; user-select: none; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);`;
const closeBtn = document.createElement('div');
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `position: absolute; top: 20px; right: 25px; color: #ffffff; font-size: 35px; cursor: pointer; user-select: none; z-index: 100002; transition: transform 0.1s;`;
function updateImage(index) {
currentIndex = (index + urls.length) % urls.length;
img.style.transform = 'scale(0.97)';
img.style.opacity = '0';
setTimeout(() => {
img.src = urls[currentIndex];
img.onload = () => {
img.style.transform = 'scale(1)';
img.style.opacity = '1';
};
}, 150);
}
if (urls.length > 1) {
const leftBtn = document.createElement('div');
leftBtn.innerHTML = '❮';
leftBtn.style.cssText = `position: absolute; left: 30px; top: 50%; transform: translateY(-50%); width: 55px; height: 55px; background: rgba(255,255,255,0.08); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; font-size: 24px; cursor: pointer; user-select: none; transition: all 0.25s ease; border: 1px solid rgba(255,255,255,0.15); z-index: 100001;`;
leftBtn.onmouseover = () => { leftBtn.style.background = 'rgba(255,255,255,0.2)'; leftBtn.style.transform = 'translateY(-50%) scale(1.08)'; };
leftBtn.onmouseout = () => { leftBtn.style.background = 'rgba(255,255,255,0.08)'; leftBtn.style.transform = 'translateY(-50%) scale(1)'; };
leftBtn.addEventListener('click', (e) => {
e.stopPropagation();
updateImage(currentIndex - 1);
});
const rightBtn = document.createElement('div');
rightBtn.innerHTML = '❯';
rightBtn.style.cssText = `position: absolute; right: 30px; top: 50%; transform: translateY(-50%); width: 55px; height: 55px; background: rgba(255,255,255,0.08); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; font-size: 24px; cursor: pointer; user-select: none; transition: all 0.25s ease; border: 1px solid rgba(255,255,255,0.15); z-index: 100001;`;
rightBtn.onmouseover = () => { rightBtn.style.background = 'rgba(255,255,255,0.2)'; rightBtn.style.transform = 'translateY(-50%) scale(1.08)'; };
rightBtn.onmouseout = () => { rightBtn.style.background = 'rgba(255,255,255,0.08)'; rightBtn.style.transform = 'translateY(-50%) scale(1)'; };
rightBtn.addEventListener('click', (e) => {
e.stopPropagation();
updateImage(currentIndex + 1);
});
overlay.appendChild(leftBtn);
overlay.appendChild(rightBtn);
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft' && urls.length > 1) {
updateImage(currentIndex - 1);
} else if (e.key === 'ArrowRight' && urls.length > 1) {
updateImage(currentIndex + 1);
} else if (e.key === 'Escape') {
closeLightbox();
}
};
const closeLightbox = () => {
win.removeEventListener('keydown', handleKeyDown);
overlay.style.opacity = '0';
setTimeout(() => {
overlay.remove();
document.documentElement.style.removeProperty('overflow');
document.body.style.removeProperty('overflow');
}, 250);
};
win.addEventListener('keydown', handleKeyDown);
closeBtn.addEventListener('click', closeLightbox);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeLightbox(); });
overlay.appendChild(img);
overlay.appendChild(closeBtn);
document.body.appendChild(overlay);
setTimeout(() => overlay.style.opacity = '1', 10);
}
// -------------------------------------------------------------------------
// 6. OBSERVER LIFECYCLE
// -------------------------------------------------------------------------
const observer = new MutationObserver(() => {
injectViewedButton();
setupPhotoViewer();
if (lastProfileDetailPageData) {
paintProfileDetailPage(lastProfileDetailPageData);
}
profileDetailsCache.forEach((data, profileId) => {
const card = document.getElementById(profileId);
if (card && !card.dataset.reconInjected) paintProfileData(profileId);
});
});
observer.observe(document.documentElement, { childList: true, subtree: true });
// Handle initial state if page is loaded
if (lastProfileDetailPageData) {
paintProfileDetailPage(lastProfileDetailPageData);
}
})();