// ==UserScript==
// @name Ultimate F95Zone
// @namespace https://github.com/balu100/Ultimate-F95Zone
// @version 1.5
// @license MIT
// @description Fully responsive - endless scrolling - wide
// @author balu100
// @match https://f95zone.to/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=f95zone.to
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const pBodyInner = document.querySelector('.p-body-inner');
if (pBodyInner) {
// Get the device width (browser window width)
const deviceWidth = window.innerWidth;
// Calculate 95% of the device width
const newWidth = deviceWidth * 0.95;
// Modify the max-width property to 95% of the device width
pBodyInner.style.maxWidth = newWidth + 'px';
}
// --- Constants and State Variables ---
const windowWidth = screen.width;
let currentPage = 1;
let isLoading = false;
let noMorePages = false;
let currentFilters = '';
let infiniteScrollInitialized = false;
let itemsPerRow = 90;
let siteOptions = { newTab: "true", version: "small", searchHighlight: "true" };
const itemContainerSelector = 'div#latest-page_items-wrap_inner';
const itemSelector = '.resource-tile';
const originalPaginationSelector = '.sub-nav_paging';
const AJAX_ENDPOINT_URL = 'https://f95zone.to/sam/latest_alpha/latest_data.php';
// --- Helper Function Definitions ---
const htmlEscape = (str) => {
if (str === null || str === undefined) {
return '';
}
let text = String(str);
text = text.replace(/&/g, '&'); // Corrected
text = text.replace(/</g, '<'); // Corrected
text = text.replace(/>/g, '>'); // Corrected
text = text.replace(/"/g, '"');// Corrected
text = text.replace(/'/g, ''');
return text;
};
const highlightUnreadLinks = () => {
try {
const links = document.querySelectorAll('a:not(.resource-item_row-hover_outer > a)');
for (const link of links) {
const text = link.textContent.trim().toLowerCase();
const buttonText = link.querySelector('.button-text');
if (link.href.includes('/unread?new=1') || text === 'jump to new' || (buttonText && buttonText.textContent.trim().toLowerCase() === 'jump to new')) {
link.classList.add('highlight-unread');
if (buttonText) buttonText.classList.add('highlight-unread');
}
}
} catch (e) {
console.error("ERROR IN highlightUnreadLinks:", e);
}
};
const getCurrentPageFromUrl = () => { const hash = window.location.hash; if (hash && hash.includes('page=')) { const match = hash.match(/page=(\d+)/); if (match && match[1]) return parseInt(match[1], 10); } return 1; };
const getCurrentFiltersFromUrl = () => { const params = new URLSearchParams(); const hash = window.location.hash; if (hash && hash.length > 1 && hash !== '#/') { let rhP = hash.substring(1); if (rhP.startsWith('/')) rhP = rhP.substring(1); const ps = rhP.split('/'); let fc=0,fs=0; ps.forEach(p => { if (p.includes('=')) { const [k, v] = p.split('='); if (k && v && k !== 'page') { params.set(k, decodeURIComponent(v)); if (k === 'cat') fc=1; if (k === 'sort') fs=1;}}}); if (!fc && !params.has('cat')) params.set('cat', 'games'); if (!fs && !params.has('sort')) params.set('sort', 'date');} else { params.set('cat', 'games'); params.set('sort', 'date');} return params.toString(); };
function createItemElement(itemData_g) {
const tile = document.createElement('div');
// 1. Base classes (CRITICAL - Must match what latest.min.js finds/expects)
let tileBaseClasses = ['resource-tile', 'userscript-generated-tile'];
if (itemData_g.ignored) tileBaseClasses.push('resource-tile_ignored');
if (itemData_g.new) tileBaseClasses.push('resource-tile_new');
else tileBaseClasses.push('resource-tile_update');
const currentCategoryFromFilters = (new URLSearchParams(currentFilters)).get('cat') || 'games';
// The site's own code adds category-specific classes to the main tile element.
// Example from their code: m.removeClass("resource-wrap-game resource-wrap-animation resource-wrap-comic resource-wrap-asset").addClass(e); (where e is derived from category)
// The tiles themselves also get classes like 'game-item', 'comic-item' etc. from other parts of their logic.
// We should try to add the most common ones if we can determine them.
// For now, it's often added to the item container (`m`), not individual tiles by this specific loop.
// However, inspect a live tile to see if it has, e.g., `game-item` or `resource-tile_game`
if (currentCategoryFromFilters) {
// tileBaseClasses.push(`${currentCategoryFromFilters}-item`); // Example
// tileBaseClasses.push(`resource-tile_${currentCategoryFromFilters}`); // Example
}
// The class `grid-item` is often added by grid/masonry libraries AFTER DOM insertion.
// XF.activate might handle this, or latest.min.js itself.
tile.className = tileBaseClasses.join(' ');
// 2. Data attributes
tile.dataset.threadId = String(itemData_g.thread_id || '0');
tile.dataset.tags = (itemData_g.tags && itemData_g.tags.length) ? itemData_g.tags.join(',') : '';
tile.dataset.images = (itemData_g.screens && itemData_g.screens.length > 0) ? itemData_g.screens.join(',') : (itemData_g.cover || '');
// Add any other data-xf-init, data-xf-click attributes seen on live tiles
// 3. Inner HTML
let threadLinkUrl = `https://f95zone.to/threads/${htmlEscape(String(itemData_g.thread_id || '0'))}/`;
let titleTextForDisplay = htmlEscape(itemData_g.title || 'No Title');
let titleTextForAttr = htmlEscape(itemData_g.title || 'No Title'); // Use htmlEscape for attributes too
let rawCreatorText = itemData_g.creator || 'Unknown Creator';
let versionText = (itemData_g.version && itemData_g.version !== "Unknown") ? htmlEscape(itemData_g.version) : "";
let coverUrl = htmlEscape(itemData_g.cover || '');
let views = itemData_g.views || 0;
let likes = itemData_g.likes || 0;
let ratingVal = itemData_g.rating !== undefined ? Number(itemData_g.rating) : 0;
let ratingDisplay = ratingVal === 0 ? "-" : ratingVal.toFixed(1);
let ratingWidth = 20 * ratingVal;
let dateHtmlForDisplay = htmlEscape(itemData_g.date || 'N/A');
const dateMatch = String(itemData_g.date).match(/^([0-9]+ )?([A-Za-z ]+)$/i);
if (dateMatch) { dateHtmlForDisplay = `<span class="tile-date_${htmlEscape(dateMatch[2]).toLowerCase().replace(/ /g, "")}">${dateMatch[1] ? htmlEscape(dateMatch[1].trim()) : ""}</span>`; }
let viewsFormatted = String(views);
if (views > 1000000) viewsFormatted = (views / 1000000).toFixed(1) + "M";
else if (views > 1000) viewsFormatted = Math.round(views / 1000) + "K";
const newTabSetting = (siteOptions.newTab === "true");
const versionStyleSmall = (siteOptions.version === "small"); // From k.version in latest.min.js
// This is the critical part. Make it match the site's generated tile HTML exactly.
// The structure below is based on the latest.min.js snippet.
tile.innerHTML = `
<a href="${threadLinkUrl}" class="resource-tile_link" rel="noopener"${newTabSetting ? ' target="_blank"' : ''}>
<div class="resource-tile_thumb-wrap">
<div class="resource-tile_thumb" style="background-image:url(${coverUrl ? `'${coverUrl}'` : 'none'})">
${itemData_g.watched ? '<i class="far fa-eye watch-icon"></i>' : ''}
</div>
</div>
<div class="resource-tile_body">
<div class="resource-tile_label-wrap">
<div class="resource-tile_label-wrap_left">
<!-- Prefixes (non-status) are built by site's ra() and M variable logic -->
</div>
<div class="resource-tile_label-wrap_right">
<!-- Status Prefixes are also built by site's ra() -->
<div class="resource-tile_label-version">${(currentCategoryFromFilters !== "assets" && versionText && versionStyleSmall) ? versionText : ""}</div>
</div>
</div>
<div class="resource-tile_info">
<header class="resource-tile_info-header">
<div class="header_title-wrap">
<h2 class="resource-tile_info-header_title">${titleTextForDisplay}</h2>
</div>
<div class="header_title-ver">${(currentCategoryFromFilters !== "assets" && versionText && !versionStyleSmall) ? versionText : ""}</div>
<div class="resource-tile_dev fas fa-user">${(currentCategoryFromFilters !== "assets") ? ` ${htmlEscape(rawCreatorText)}` : ""}</div>
</header>
<div class="resource-tile_info-meta">
<div class="resource-tile_info-meta_time">${dateHtmlForDisplay}</div>
<div class="resource-tile_info-meta_likes">${htmlEscape(String(likes))}</div>
<div class="resource-tile_info-meta_views">${htmlEscape(viewsFormatted)}</div>
${(currentCategoryFromFilters !== "assets" && currentCategoryFromFilters !== "comics") ? `<div class="resource-tile_info-meta_rating">${ratingDisplay}</div>` : ""}
<div class="resource-tile_rating"><span style="width:${ratingWidth}%"></span></div>
</div>
</div>
</div>
</a>
<!-- Hover elements like buttons, gallery, tags are added by latest.min.js on mouseenter -->
`;
return tile;
}
async function loadMoreItems() {
if (isLoading || noMorePages) { return; }
isLoading = true;
const basePage = Number(currentPage);
if (isNaN(basePage)) {
console.error(`loadMoreItems: currentPage is NaN! Val: ${currentPage}`);
isLoading = false; noMorePages = true;
const elT = document.getElementById('infinite-scroll-trigger'); if (elT) elT.classList.add('no-more');
return;
}
const nextPageToLoad = basePage + 1;
const loadTrigger = document.getElementById('infinite-scroll-trigger');
if (loadTrigger) loadTrigger.classList.add('loading');
try {
const ajaxParams = new URLSearchParams(currentFilters);
ajaxParams.set('cmd', 'list'); ajaxParams.set('page', nextPageToLoad.toString());
ajaxParams.set('rows', itemsPerRow.toString()); ajaxParams.set('_', Date.now().toString());
const urlToFetch = `${AJAX_ENDPOINT_URL}?${ajaxParams.toString()}`;
const response = await fetch(urlToFetch, {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json, text/javascript, */*; q=0.01' }
});
if (!response.ok) {
console.error(`loadMoreItems: Failed fetch. Status: ${response.status}`);
noMorePages = true; if (loadTrigger) { loadTrigger.classList.remove('loading'); loadTrigger.classList.add('no-more'); }
return;
}
const responseData = await response.json();
if (responseData && responseData.status === 'ok' && responseData.msg && responseData.msg.data) {
const newItemsData = responseData.msg.data;
const container = document.querySelector(itemContainerSelector);
if (newItemsData.length > 0 && container) {
const fragment = document.createDocumentFragment();
const addedElements = [];
newItemsData.forEach(itemData => {
const itemElement = createItemElement(itemData);
fragment.appendChild(itemElement);
addedElements.push(itemElement);
});
container.appendChild(fragment);
if (typeof XF !== 'undefined' && typeof XF.activate === 'function') {
addedElements.forEach(el => XF.activate(el));
}
currentPage = nextPageToLoad;
highlightUnreadLinks();
if (responseData.msg.pagination && responseData.msg.pagination.page >= responseData.msg.pagination.total) {
noMorePages = true; if (loadTrigger) loadTrigger.classList.add('no-more');
}
} else {
noMorePages = true; if (loadTrigger) loadTrigger.classList.add('no-more');
}
} else {
noMorePages = true; if (loadTrigger) loadTrigger.classList.add('no-more');
console.error(`loadMoreItems: AJAX response error or bad data.`, responseData);
}
} catch (error) {
console.error('loadMoreItems: Error during AJAX/processing:', error);
noMorePages = true; if (loadTrigger) { loadTrigger.classList.remove('loading'); loadTrigger.classList.add('no-more'); }
} finally {
isLoading = false;
if (loadTrigger && !noMorePages) loadTrigger.classList.remove('loading');
}
}
function actualInitInfiniteScroll() {
if (infiniteScrollInitialized) return;
const mainContentArea = document.querySelector(itemContainerSelector);
if (!mainContentArea || !mainContentArea.querySelector(itemSelector)) { console.warn('actualInit: Container/initial items not found.'); return; }
currentPage = getCurrentPageFromUrl(); currentFilters = getCurrentFiltersFromUrl();
if (isNaN(Number(currentPage))) { console.error("actualInit: currentPage NaN, forcing 1"); currentPage = 1; }
infiniteScrollInitialized = true; console.log(`actualInit: page ${currentPage}, filters '${currentFilters}'`);
if (typeof latestUpdates !== 'undefined' && latestUpdates.options) { siteOptions = latestUpdates.options; itemsPerRow = parseInt(siteOptions.rows, 10) || 90;} else if (typeof siteOptions !== 'undefined' && siteOptions.rows) { itemsPerRow = parseInt(siteOptions.rows, 10) || 90; } else { console.warn("actualInit: latestUpdates.options not found, using script defaults for siteOptions."); }
const loadTrigger = document.createElement('div'); loadTrigger.id = 'infinite-scroll-trigger'; mainContentArea.insertAdjacentElement('afterend', loadTrigger);
const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting && !isLoading && !noMorePages) loadMoreItems();}, { threshold: 0.01 });
observer.observe(loadTrigger);
const filterChangeObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList' && mutation.removedNodes.length > 0 && Array.from(mutation.addedNodes).some(n => n.matches && n.matches(itemSelector))) { setTimeout(() => { currentPage = getCurrentPageFromUrl(); currentFilters = getCurrentFiltersFromUrl(); if(isNaN(Number(currentPage))) currentPage=1; noMorePages=false; isLoading=false; if(loadTrigger)loadTrigger.className=''; if(!mainContentArea.querySelector(itemSelector)){noMorePages=true;if(loadTrigger)loadTrigger.classList.add('no-more');}},300); break;}}});
filterChangeObserver.observe(mainContentArea, { childList: true });
}
// --- Styles ---
const style = document.createElement('style');
style.innerHTML = `
.pageContent {max-width: ${windowWidth * 0.95}px !important;max-height: 360px !important;transition: none !important;top: 110px !important;}
.p-body-inner, .p-nav-inner {max-width: ${windowWidth * 0.95}px !important;margin-left: auto !important;margin-right: auto !important;transition: none !important;box-sizing: border-box !important;}
.cover-hasImage {height: 360px !important;transition: none !important;}
.p-sectionLinks,.uix_extendedFooter,.p-footer-inner,.view-thread.block--similarContents.block-container,.js-notices.notices--block.notices, ${originalPaginationSelector} {display: none !important;}
.highlight-unread {color: cyan;font-weight: bold;text-shadow: 1px 1px 2px black;}
.uix_contentWrapper {max-width: 100% !important;padding-left: 5px !important;padding-right: 5px !important;box-sizing: border-box !important;}
.p-body-main--withSideNav {display: flex !important;flex-direction: row !important;max-width: 100% !important;padding: 0 !important;box-sizing: border-box !important;}
main#latest-page_main-wrap {flex-grow: 1 !important;margin-left: 0 !important;margin-right: 10px !important;min-width: 0;box-sizing: border-box !important;}
aside#latest-page_filter-wrap {flex-shrink: 0 !important;width: 280px !important;margin-left: 0 !important;margin-right: 0 !important;box-sizing: border-box !important;}
aside#latest-page_filter-wrap.filter-hidden,aside#latest-page_filter-wrap[style*="display:none"] {display: none !important;}
main#latest-page_main-wrap:has(+ aside#latest-page_filter-wrap.filter-hidden),main#latest-page_main-wrap:has(+ aside#latest-page_filter-wrap[style*="display:none"]) {margin-right: 0 !important;}
div#latest-page_items-wrap {width: 100% !important;margin-left: 0 !important;box-sizing: border-box !important;}
${itemContainerSelector}.resource-wrap-game.grid-normal {display: grid !important;grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important;gap: 15px !important;padding: 0 !important;box-sizing: border-box !important;}
#infinite-scroll-trigger {padding: 20px;text-align: center;font-size: 1.2em;color: #777; border: 1px solid transparent !important; min-height: 40px !important; margin-top: 10px !important; }
#infinite-scroll-trigger.loading::after {content: "Loading more items...";}
#infinite-scroll-trigger.no-more::after {content: "No more items to load.";}
.userscript-generated-tile .resource-tile_thumb { background-size: cover; background-position: center; background-repeat: no-repeat; }
.userscript-generated-tile .resource-tile_dev.fas.fa-user::before { margin-right: 0.3em; }
`;
document.documentElement.appendChild(style);
// --- Start Execution ---
highlightUnreadLinks();
setTimeout(() => {
const mainContentArea = document.querySelector(itemContainerSelector);
if (mainContentArea && mainContentArea.querySelector(itemSelector)) {
actualInitInfiniteScroll();
} else {
console.error("Initial items NOT found after 3s for init. Check itemSelector or site loading.");
}
}, 3000);
})();