Enhanced kurosearch.com search and viewer with tags, filters, recommendations, and fullscreen mode.
// ==UserScript==
// @name kurosearch Studio
// @name:ru kurosearch Studio
// @namespace kurosearch-studio
// @version 1.0.1
// @license MIT
// @description Enhanced kurosearch.com search and viewer with tags, filters, recommendations, and fullscreen mode.
// @description:ru Интерфейс поиска и просмотра kurosearch.com с тегами, фильтрами, рекомендациями и полноэкранным режимом.
// @match https://kurosearch.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(() => {
'use strict';
if (window.__kxStudio) return;
window.__kxStudio = true;
/* ====================== настройки ====================== */
const API_URLS = [
'https://rule34-api.netlify.app',
'https://rule-34-api-vercel.vercel.app',
'https://rule-34-api.shirozenzen.workers.dev'
];
const AUTOCOMPLETE_URL = 'https://api.rule34.xxx/autocomplete.php';
const LIMIT = 20; // размер страницы API (как на самом сайте)
const MIN_NEW = 24; // сколько постов добирать за один раз (с учётом фильтров)
const MAX_PAGES_PER_LOAD = 10;
const FLIP_FRAMES = 6; // сколько кадров крутить в превью видео
const FLIP_MS = 240; // скорость смены кадров, мс
const NEXT_LIST_SIZE = 60; // сколько артов показывать в правом списке «дальше»
const CFG_KEY = 'kx-studio-v2';
const DEFAULTS = {
layout: 'feed', // 'feed' — лента, 'grid' — сетка по 3 колонки
sortProp: 'id',
sortDir: 'desc',
rating: 'all',
minScore: 0,
showImage: true,
showGif: true,
showVideo: true,
blocked: '', // автоскрытие тегов (поддерживает *)
required: '',
minW: 0,
minH: 0,
hideComics: false,
tags: [], // [{m:'+'|'-'|'~', n:'tag_name'}]
feed: 'foryou', // 'all' — обычная выдача, 'foryou' — лента по вашим вкусам
views: {}, // сколько раз вы смотрели арты с тегом (профиль вкусов)
favorites: [], // избранные теги
filtersOpen: false,
lang: 'ru',
ageOk: false
};
const loadCfg = () => {
try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(CFG_KEY) || '{}') }; }
catch { return { ...DEFAULTS }; }
};
let cfg = loadCfg();
if (!['ru', 'en'].includes(cfg.lang)) cfg.lang = 'ru';
if (!cfg.views || typeof cfg.views !== 'object' || Array.isArray(cfg.views)) cfg.views = {};
if (!Array.isArray(cfg.favorites)) cfg.favorites = [];
const mediaSettingsValid = (cfg.showImage && cfg.showGif && cfg.showVideo) ||
(cfg.showImage && !cfg.showGif && !cfg.showVideo) || (!cfg.showImage && cfg.showGif && cfg.showVideo);
if (!mediaSettingsValid) cfg.showImage = cfg.showGif = cfg.showVideo = true;
const saveCfg = () => { try { localStorage.setItem(CFG_KEY, JSON.stringify(cfg)); } catch {} };
const TEXT = {
ru: {
languageLabel: 'Язык',
origTitle: 'Показать оригинальный сайт',
searchPlaceholder: 'Теги… «-» исключить, «~» любой из',
sortLabel: 'Сортировка',
newest: 'Новые',
highestScore: 'По оценке',
recentlyUpdated: 'Обновлённые',
random: 'Случайные',
sortDirection: 'Направление',
mediaLabel: 'Медиа',
all: 'Все',
images: 'Арты',
animated: 'GIF и видео',
blockedLabel: 'Автоскрытие тегов (ПКМ по тегу, чтобы добавить)',
blockedPlaceholder: 'Тег или маска * — Enter',
requiredLabel: 'Обязательные теги',
hideComics: 'Скрывать комиксы',
reset: 'Сбросить',
favoritesTab: '★ Избранные',
filtersTab: 'Фильтры',
forYou: '✦ Для вас',
closeViewer: 'Закрыть (Esc)',
fullscreen: 'На весь экран (или клик по арту)',
gateTitle: 'Только для взрослых',
gateCopy: 'Сайт содержит материалы 18+. Продолжая, вы подтверждаете, что вам исполнилось 18 лет и просмотр такого контента разрешён там, где вы находитесь.',
ageConfirm: 'Мне есть 18',
leave: 'Уйти',
backToStudio: 'Вернуться в Studio',
currentTasteFeed: '✦ по вашим вкусам',
posts: 'постов',
shown: 'показано',
hidden: 'скрыто',
apiUnavailable: 'API недоступно',
video: 'видео',
grid: '▦ Сетка 3×',
feed: '▤ Лента',
loadFailed: 'Не удалось загрузить: ',
retry: 'Повторить',
nothingFound: 'Ничего не найдено. Попробуйте смягчить фильтры.',
endOfResults: '— конец выдачи —',
forYouFallback: '✦ «Для вас» пока работает как обычная лента. Добавьте избранные теги (ПКМ по тегу → «Добавить в избранное») или посмотрите несколько артов, и лента подстроится под вас.',
addFavorite: 'Добавить в избранное',
removeFavorite: 'Убрать из избранного',
hideTag: 'Скрыть тег',
unhideTag: 'Не скрывать тег',
removeBlocked: 'Убрать из автоскрытия',
cycleTag: 'Клик — сменить режим (+ / − / ~)',
exclude: 'Исключить',
anyOf: 'Любой из',
currentResults: 'В текущей выдаче',
enterTag: 'Начните вводить тег',
tagHints: 'Подсказки появятся здесь по мере ввода, а после загрузки арты предложат свои теги.',
postTags: 'Теги арта',
postTagHelp: 'Клик — добавить в поиск, ПКМ — избранное или скрыть.',
favoritesTitle: '★ Избранные теги',
emptyFavorites: 'Пока пусто. Нажмите правой кнопкой по любому тегу и выберите «Добавить в избранное». На избранных авторах и персонажах строится лента «Для вас».',
artists: 'Авторы',
characters: 'Персонажи',
copyrights: 'Лицензии',
others: 'Остальные',
interests: 'Похоже, вам нравится',
interestsHelp: 'Собрано по просмотренным артам. ПКМ → избранное.',
suggestions: 'Подсказки',
enterToAdd: 'Enter — добавить «{term}»',
next: 'Дальше',
lastPost: 'Это последний арт',
loading: 'Загрузка…',
loadingOriginal: 'Загружаю оригинал…',
originalFailed: 'Оригинал не загрузился, показано превью',
post: 'Пост',
file: 'Файл',
source: 'Источник'
},
en: {
languageLabel: 'Language',
origTitle: 'Show original website',
searchPlaceholder: 'Tags… “-” exclude, “~” any of',
sortLabel: 'Sort',
newest: 'Newest',
highestScore: 'Highest score',
recentlyUpdated: 'Recently updated',
random: 'Random',
sortDirection: 'Sort direction',
mediaLabel: 'Media',
all: 'All',
images: 'Images',
animated: 'GIFs & videos',
blockedLabel: 'Auto-hide tags (right-click a tag to add)',
blockedPlaceholder: 'Tag or wildcard * — Enter',
requiredLabel: 'Required tags',
hideComics: 'Hide comics',
reset: 'Reset',
favoritesTab: '★ Favorites',
filtersTab: 'Filters',
forYou: '✦ For you',
closeViewer: 'Close (Esc)',
fullscreen: 'Fullscreen (or click the artwork)',
gateTitle: 'Adults only',
gateCopy: 'This site contains 18+ material. By continuing, you confirm that you are at least 18 years old and that viewing this content is permitted where you are located.',
ageConfirm: 'I am 18 or older',
leave: 'Leave',
backToStudio: 'Return to Studio',
currentTasteFeed: '✦ For you',
posts: 'posts',
shown: 'shown',
hidden: 'hidden',
apiUnavailable: 'API unavailable',
video: 'video',
grid: '▦ Grid 3×',
feed: '▤ Feed',
loadFailed: 'Could not load: ',
retry: 'Retry',
nothingFound: 'Nothing found. Try loosening your filters.',
endOfResults: '— end of results —',
forYouFallback: '✦ Your For you feed is using the regular results for now. Add favorite tags (right-click a tag → “Add to favorites”) or view a few artworks so the feed can learn your preferences.',
addFavorite: 'Add to favorites',
removeFavorite: 'Remove from favorites',
hideTag: 'Hide tag',
unhideTag: 'Unhide tag',
removeBlocked: 'Remove from auto-hide',
cycleTag: 'Click to change mode (+ / − / ~)',
exclude: 'Exclude',
anyOf: 'Any of',
currentResults: 'In current results',
enterTag: 'Start typing a tag',
tagHints: 'Suggestions will appear as you type; loaded artworks will also provide tags.',
postTags: 'Artwork tags',
postTagHelp: 'Click to search; right-click to favorite or hide.',
favoritesTitle: '★ Favorite tags',
emptyFavorites: 'Nothing here yet. Right-click any tag and choose “Add to favorites”. Favorite artists and characters help build your For you feed.',
artists: 'Artists',
characters: 'Characters',
copyrights: 'Copyrights',
others: 'Other',
interests: 'You may like',
interestsHelp: 'Based on artworks you viewed. Right-click a tag to favorite it.',
suggestions: 'Suggestions',
enterToAdd: 'Press Enter to add “{term}”',
next: 'Up next',
lastPost: 'This is the last artwork',
loading: 'Loading…',
loadingOriginal: 'Loading original…',
originalFailed: 'Could not load the original; showing preview',
post: 'Post',
file: 'File',
source: 'Source'
}
};
const t = (key) => TEXT[cfg.lang][key];
/* ====================== утилиты ====================== */
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const decodeEntities = (s) =>
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/�?39;/g, "'");
const splitTags = (s) => s.toLowerCase().split(/[\s,]+/).map((t) => t.trim()).filter(Boolean);
const toRe = (pat) =>
new RegExp('^' + pat.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
const normName = (s) => s.trim().toLowerCase().replace(/\s+/g, '_');
const isImg = (u = '') => /\.(jpe?g|png|webp)(\?|$)/i.test(u);
const isGifUrl = (u = '') => /\.gif(\?|$)/i.test(u);
const isVidUrl = (u = '') => /\.(mp4|webm)(\?|$)/i.test(u);
const postType = (file) => (isVidUrl(file) ? 'video' : isGifUrl(file) ? 'gif' : 'image');
/* ====================== состояние ====================== */
const S = {
gen: 0, ctrl: null, streams: [], noProfile: false,
ids: new Set(), raw: [], view: [], rendered: 0,
loading: false, ended: false, count: null, err: null,
tagFreq: new Map(),
fs: -1, cols: [], colH: [],
sug: [], hl: -1
};
/* ====================== API ====================== */
let apiIdx = 0;
async function api(path, signal) {
let lastErr;
for (let k = 0; k < API_URLS.length; k++) {
const i = (apiIdx + k) % API_URLS.length;
try {
const r = await fetch(API_URLS[i] + path, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
apiIdx = i;
return r;
} catch (e) {
if (e && e.name === 'AbortError') throw e;
lastErr = e;
}
}
throw lastErr || new Error(t('apiUnavailable'));
}
/* ====================== типы тегов (цвета и порядок) ====================== */
const TYPE_KEY = 'kx-tagtypes-v1';
const TYPE_IDS = ['general', 'artist', 'general', 'copyright', 'character', 'metadata']; // id как у сайта
const TYPE_ORDER = { artist: 0, character: 1, copyright: 2, source: 3, metadata: 4, rating: 5, general: 6 };
let typeCache = {};
try { typeCache = JSON.parse(localStorage.getItem(TYPE_KEY) || '{}'); } catch {}
const normalizeType = (type) => /^\d+$/.test(String(type)) ? TYPE_IDS[Number(type)] || 'general' : type;
Object.keys(typeCache).forEach((n) => { typeCache[n] = normalizeType(typeCache[n]); });
const saveTypes = debounce(() => {
try {
const keys = Object.keys(typeCache);
if (keys.length > 6000) for (const k of keys.slice(0, keys.length - 5000)) delete typeCache[k];
localStorage.setItem(TYPE_KEY, JSON.stringify(typeCache));
} catch {}
}, 1000);
const typeOf = (n) => typeCache[n];
const typeRank = (n) => (typeCache[n] in TYPE_ORDER ? TYPE_ORDER[typeCache[n]] : 7);
const typeCls = (n) => (typeCache[n] && typeCache[n] !== 'general' ? ' t-' + typeCache[n] : '');
const byImportance = (a, b) => typeRank(a) - typeRank(b) || (a < b ? -1 : a > b ? 1 : 0);
const typeQueue = [], typeBusy = new Set(), typeFailed = new Set();
let typeActive = 0, typeFails = 0, typeTrip = null;
const onTypesUpdated = debounce(() => {
renderActive();
paintSuggestions();
renderTagPane(true);
if (tab === 'fav') renderFavPanel();
}, 250);
function wantTypes(names, urgent) {
const fresh = names.filter((n) => n && !typeCache[n] && !typeBusy.has(n) && !typeFailed.has(n));
fresh.forEach((n) => typeBusy.add(n));
if (urgent) typeQueue.unshift(...fresh); else typeQueue.push(...fresh);
pumpTypes();
}
function pumpTypes() {
if (typeFails >= 6) {
if (!typeTrip) typeTrip = setTimeout(() => { typeFails = 0; typeTrip = null; pumpTypes(); }, 60000);
return;
}
while (typeActive < 4 && typeQueue.length) {
const n = typeQueue.shift();
typeActive++;
fetchType(n).finally(() => { typeActive--; typeBusy.delete(n); pumpTypes(); });
}
}
async function fetchType(n) {
try {
const r = await api(`/tag-details?name=${encodeURIComponent(n)}`);
const m = /<tag\b[^>]*?\btype="(\d+)"/.exec(await r.text());
if (m) { typeCache[n] = TYPE_IDS[Number(m[1])] || 'general'; typeFails = 0; saveTypes(); onTypesUpdated(); }
else typeFailed.add(n);
} catch { typeFails++; typeFailed.add(n); }
}
/* ====================== вкусы и рекомендации ====================== */
let tab = '';
const TYPE_MULT = { artist: 3, character: 2.2, copyright: 1.6, general: 0.15, metadata: 0.02 };
const seenViews = new Set();
function affinity(t) {
const ty = typeCache[t] || 'general';
let w = Math.log2(1 + (cfg.views[t] || 0)) * 4 * (TYPE_MULT[ty] ?? 0.15);
if (cfg.favorites.includes(t)) w += ty === 'artist' ? 60 : ty === 'general' ? 8 : 24;
return w;
}
const postScore = (p) => p.tags.reduce((sum, t) => sum + affinity(t), 0) / Math.sqrt(p.tags.length || 1);
function recordView(p) {
if (seenViews.has(p.id)) return;
seenViews.add(p.id);
for (const t of p.tags) cfg.views[t] = (cfg.views[t] || 0) + 1;
const keys = Object.keys(cfg.views);
if (keys.length > 800) keys.sort((a, b) => cfg.views[a] - cfg.views[b]).slice(0, keys.length - 600).forEach((k) => delete cfg.views[k]);
saveCfg();
}
// теги, которые вы смотрите чаще всего (только авторы / персонажи / лицензии)
function interestTags(limit, minViews = 3) {
return Object.entries(cfg.views)
.filter(([t, v]) => v >= minViews && ['artist', 'character', 'copyright'].includes(typeCache[t]))
.map(([t, v]) => [t, Math.log2(1 + v) * TYPE_MULT[typeCache[t]]])
.sort((a, b) => b[1] - a[1]).slice(0, limit).map((e) => e[0]);
}
function profile() {
const blocked = splitTags(cfg.blocked).map(toRe);
const inSearch = new Set(cfg.tags.map((t) => t.n));
const ok = (n) => !inSearch.has(n) && !blocked.some((re) => re.test(n));
const artists = cfg.favorites.filter((f) => typeCache[f] === 'artist' && ok(f));
const favOther = cfg.favorites.filter((f) => ['character', 'copyright'].includes(typeCache[f]) && ok(f));
const learned = interestTags(30).filter((n) => ok(n) && !artists.includes(n) && !favOther.includes(n));
return { artists, disc: [...favOther, ...learned].slice(0, 8) };
}
const encTag = (n) => encodeURIComponent(n);
const orGroup = (names) => `( ${names.map(encTag).join(' ~ ')} )`;
function buildQuery(extra = []) {
const parts = [`sort:${cfg.sortProp}:${cfg.sortDir}`];
const plus = cfg.tags.filter((t) => t.m === '+').map((t) => encTag(t.n));
const minus = cfg.tags.filter((t) => t.m === '-').map((t) => '-' + encTag(t.n));
const any = cfg.tags.filter((t) => t.m === '~').map((t) => encTag(t.n));
parts.push(...plus, ...minus);
if (any.length) parts.push(`( ${any.join(' ~ ')} )`);
parts.push(...extra);
return parts.join('+');
}
function normPost(p) {
const info = Array.isArray(p.tag_info) ? p.tag_info : null;
let tags;
if (info) {
tags = info.map((t) => decodeEntities(t.tag).toLowerCase());
info.forEach((t) => { if (t.type != null) typeCache[decodeEntities(t.tag).toLowerCase()] = normalizeType(t.type); });
saveTypes();
} else {
tags = String(p.tags || '').split(' ').filter(Boolean).map((t) => decodeEntities(t).toLowerCase());
}
const file = p.file_url || '';
return {
id: Number(p.id),
w: Number(p.width) || 0,
h: Number(p.height) || 0,
score: Number(p.score) || 0,
rating: p.rating || '',
file,
sample: p.sample_url || '',
prev: p.preview_url || '',
source: p.source || '',
type: postType(file),
tags
};
}
const thumbSrc = (p) => {
if (p.type === 'image') return isImg(p.sample) ? p.sample : p.prev || p.file;
if (p.type === 'gif') return isGifUrl(p.sample) ? p.prev : p.sample || p.prev;
return p.prev || p.sample;
};
const videoSrc = (p) => (isVidUrl(p.sample) ? p.sample : p.file);
const ratioOf = (p) => (p.w && p.h ? clamp(p.w / p.h, 0.4, 2.5) : 1);
/* ====================== клиентские фильтры ====================== */
let keep = () => true;
function rebuildKeep() {
const blocked = splitTags(cfg.blocked).map(toRe);
const req = splitTags(cfg.required);
keep = (p) => {
if (p.type === 'image' && !cfg.showImage) return false;
if (p.type === 'gif' && !cfg.showGif) return false;
if (p.type === 'video' && !cfg.showVideo) return false;
if (cfg.hideComics && p.w && p.h && p.w / p.h < 0.4) return false;
if (blocked.length && p.tags.some((t) => blocked.some((re) => re.test(t)))) return false;
if (req.length && !req.every((t) => p.tags.includes(t))) return false;
return true;
};
}
rebuildKeep();
/* ====================== стили ====================== */
const CSS = `
#kx-root{--bg:#0b0b11;--fg:#ececf6;--mut:#8d8da4;--sur:rgba(255,255,255,.055);--sur2:rgba(255,255,255,.10);
--acc:#8b5cf6;--acc2:#ec4899;--inc:rgba(139,92,246,.28);--exc:rgba(244,63,94,.28);--any:rgba(245,158,11,.26);
--r:20px;--ease:cubic-bezier(.22,.8,.3,1);
position:fixed;inset:0;z-index:2147483000;display:flex;background:var(--bg);color:var(--fg);
font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
#kx-root.away{display:none}
#kx-root *{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:#ffffff24 transparent}
:where(#kx-root) :where(button,input,select,textarea){font:inherit;color:inherit;border:0;outline:0}
:where(#kx-root) button{cursor:pointer;background:none}
#kx-root :where(.kx-tag,.kx-chip,.kx-sug,.kx-li,.kx-ico,.kx-pill,.kx-seg button,.kx-tabs button,.kx-blocked-tag,.kx-tagmenu button){
cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
.kx-side{width:min(390px,100%);flex:none;min-height:0;display:flex;flex-direction:column;gap:14px;padding:20px 18px 16px;
background:radial-gradient(120% 60% at 0% 0%,#1d1530 0%,transparent 60%),linear-gradient(180deg,#14141d,#0e0e15);
box-shadow:10px 0 50px #0009;z-index:3}
.kx-brand{display:flex;align-items:center;gap:10px}
.kx-logo{font-weight:800;font-size:20px;letter-spacing:.3px;color:#ef2f3f;text-shadow:0 0 22px rgba(239,47,63,.35)}
.kx-lang{width:82px;padding:7px 9px;border-radius:12px;background:var(--sur);cursor:pointer}
.kx-sp{flex:1}
.kx-ico{width:34px;height:34px;border-radius:50%;background:var(--sur);display:grid;place-items:center;transition:.25s var(--ease)}
.kx-ico:hover{background:var(--sur2);transform:scale(1.06)}
.kx-search{display:flex;align-items:center;gap:10px;padding:0 16px;height:50px;border-radius:999px;background:var(--sur);
box-shadow:inset 0 2px 10px #0006;transition:.3s var(--ease)}
.kx-search:focus-within{background:var(--sur2);box-shadow:inset 0 2px 10px #0006,0 0 0 4px rgba(139,92,246,.18)}
.kx-search input{flex:1;min-width:0;background:none;font-size:15px}
.kx-search input::placeholder{color:var(--mut)}
.kx-active{display:flex;flex-wrap:wrap;gap:7px;min-height:0}
.kx-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 8px 6px 12px;border-radius:999px;background:var(--inc);
transition:.25s var(--ease);max-width:100%;cursor:pointer}
.kx-chip.m-minus{background:var(--exc)} .kx-chip.m-any{background:var(--any)}
.kx-chip::before,.kx-sug::before{content:"";flex:none;width:8px;height:8px;border-radius:50%;background:var(--tc,#6b6b80)}
.kx-chip{padding-left:10px}
.t-artist{--tc:#ef4444;--tb:rgba(239,68,68,.28);--tt:#fecaca}
.t-character{--tc:#22c55e;--tb:rgba(34,197,94,.25);--tt:#bbf7d0}
.t-copyright{--tc:#eab308;--tb:rgba(234,179,8,.25);--tt:#fef08a}
.t-metadata{--tc:#38bdf8;--tb:rgba(56,189,248,.22);--tt:#bae6fd}
.kx-chip:hover{transform:translateY(-1px);filter:brightness(1.2)}
.kx-chip .n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kx-chip .x{width:20px;height:20px;border-radius:50%;background:#ffffff1f;display:grid;place-items:center;font-size:12px;line-height:1}
.kx-chip .x:hover{background:#ffffff40}
.kx-tagpane{flex:1;min-height:0;overflow:auto;border-radius:var(--r);background:var(--sur);padding:14px;
display:flex;flex-direction:column;gap:12px}
.kx-sec{font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:var(--mut);margin:0 0 2px}
.kx-cloud{display:flex;flex-wrap:wrap;gap:7px}
.kx-tag{display:inline-flex;align-items:center;gap:7px;padding:6px 6px 6px 12px;border-radius:999px;
background:var(--tb,rgba(255,255,255,.09));color:var(--tt,#d6d6e2);transition:.25s var(--ease);max-width:100%;cursor:pointer}
.kx-tag:hover{filter:brightness(1.4);transform:translateY(-1px)}
.kx-tag .n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kx-tag .c{color:var(--mut);font-size:12px}
.kx-tag .ex{width:20px;height:20px;border-radius:50%;background:#ffffff1a;display:grid;place-items:center;font-size:13px;line-height:1;opacity:.6}
.kx-tag .ex:hover{background:var(--exc);opacity:1}
.kx-sug{display:flex;align-items:center;gap:8px;padding:9px 8px 9px 14px;border-radius:14px;transition:.2s var(--ease);cursor:pointer}
.kx-sug:hover,.kx-sug.hl{background:var(--tb,rgba(255,255,255,.1))}
.kx-sug .n{color:var(--tt,var(--fg))}
.kx-tag.used{opacity:.45}
.kx-sug .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kx-sug .c{color:var(--mut);font-size:12px}
.kx-sug .b{width:24px;height:24px;border-radius:50%;background:#ffffff14;display:grid;place-items:center;font-size:13px}
.kx-sug .b:hover{background:var(--sur2)}
.kx-empty{color:var(--mut);padding:6px 2px}
.kx-body{flex:1;min-height:0;display:flex;flex-direction:column}
.kx-panel{flex:1;min-height:0;overflow:auto;border-radius:var(--r);background:var(--sur);padding:14px;display:none}
.kx-side[data-tab="fav"] #kx-favpanel{display:flex;flex-direction:column;gap:14px;animation:kxin .3s var(--ease) both}
.kx-side[data-tab="filters"] #kx-filters{display:block;animation:kxin .3s var(--ease) both}
.kx-side[data-tab="fav"] #kx-tagpane,.kx-side[data-tab="filters"] #kx-tagpane{display:none}
#kx-filters .kx-fin{padding:0}
.kx-tabs{display:flex;gap:6px;padding:5px;border-radius:999px;background:var(--sur);flex:none}
.kx-tabs button{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 10px;border-radius:999px;
color:var(--mut);transition:.25s var(--ease)}
.kx-tabs button:hover{color:var(--fg);background:var(--sur)}
.kx-tabs button.on{background:rgba(255,255,255,.16);color:#fff}
.kx-badge{min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:linear-gradient(90deg,var(--acc),var(--acc2));
font-size:12px;display:none;place-items:center;color:#fff}
.kx-badge.on{display:grid}
.kx-favmark{color:#facc15;font-size:11px}
.kx-feedseg{flex:none;width:auto}
.kx-feedseg button{flex:none;padding:7px 16px}
.kx-note{max-width:900px;margin:4px auto 14px;padding:10px 18px;border-radius:18px;background:var(--sur);color:var(--mut);font-size:13px;text-align:center}
.kx-fin{display:flex;flex-direction:column;gap:12px;padding:2px 16px 16px}
.kx-row{display:flex;align-items:center;gap:8px}
.kx-row>label,.kx-lbl{color:var(--mut);font-size:12px;min-width:74px}
.kx-fin select,.kx-fin input[type=number],.kx-fin textarea{background:var(--sur2);border-radius:14px;padding:8px 12px;width:100%;min-width:0}
.kx-fin select{appearance:none;cursor:pointer}
.kx-fin select option{background:#1a1a24}
.kx-fin textarea{min-height:54px;resize:vertical}
.kx-blocked-list{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;border-radius:14px;background:var(--sur2);min-height:42px}
.kx-blocked-list input{flex:1;min-width:110px;background:none;padding:4px}
.kx-blocked-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;background:var(--exc);font-size:12px;max-width:100%}
.kx-blocked-tag span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.kx-blocked-tag b{font-size:13px;font-weight:400}
.kx-seg{display:flex;flex:1;gap:4px;padding:4px;border-radius:999px;background:var(--sur)}
.kx-seg button{flex:1;padding:6px 4px;border-radius:999px;font-size:12px;color:var(--mut);transition:.25s var(--ease)}
.kx-seg button.on{background:rgba(255,255,255,.18);color:var(--fg)}
.kx-pill{padding:8px 14px;border-radius:999px;background:var(--sur2);transition:.25s var(--ease);font-size:13px}
.kx-pill:hover{background:rgba(139,92,246,.35)}
.kx-pill.on{background:linear-gradient(90deg,var(--acc),var(--acc2))}
.kx-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative}
.kx-head{display:flex;align-items:center;gap:12px;padding:12px 20px;background:rgba(11,11,17,.72);backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px);z-index:2}
.kx-stat{color:var(--mut);font-size:13px;display:flex;align-items:center;gap:10px}
.kx-spin{width:14px;height:14px;border-radius:50%;background:conic-gradient(var(--acc),var(--acc2),transparent);
animation:kxs .8s linear infinite;-webkit-mask:radial-gradient(circle 4px at center,transparent 98%,#000);mask:radial-gradient(circle 4px at center,transparent 98%,#000)}
@keyframes kxs{to{transform:rotate(360deg)}}
.kx-scroll{flex:1;min-height:0;overflow:auto;scroll-behavior:smooth}
.kx-grid{display:flex;gap:14px;padding:0 20px 20px;margin:0 auto;align-items:flex-start}
.kx-grid.feed{max-width:900px}
.kx-col{flex:1;min-width:0;display:flex;flex-direction:column;gap:14px}
.kx-card{position:relative;border-radius:22px;overflow:hidden;background:#171722;cursor:zoom-in;width:100%;
box-shadow:0 10px 30px #0006;transition:transform .35s var(--ease),box-shadow .35s var(--ease);animation:kxin .5s var(--ease) both}
.kx-card:hover{transform:translateY(-3px) scale(1.012);box-shadow:0 18px 44px #000a}
@keyframes kxin{from{opacity:0;transform:translateY(14px)}}
.kx-card img,.kx-card video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;display:block}
.kx-card video{opacity:0;transition:opacity .3s}
.kx-card video.on{opacity:1}
.kx-tg{position:absolute;left:10px;bottom:10px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.45);backdrop-filter:blur(8px);font-size:12px}
.kx-sc{position:absolute;right:10px;bottom:10px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.45);backdrop-filter:blur(8px);font-size:12px}
.kx-msg{text-align:center;color:var(--mut);padding:40px 20px}
.kx-msg button{margin-top:10px}
/* полноэкранный режим */
#kx-fs{position:absolute;inset:0;z-index:10;background:rgba(5,5,9,.96);opacity:0;pointer-events:none;transition:opacity .3s var(--ease)}
#kx-fs.open{opacity:1;pointer-events:auto}
.kx-stage{position:absolute;inset:0;transition:padding .3s var(--ease)}
.kx-stage{cursor:zoom-in}
#kx-fs.deep{position:fixed;z-index:40;background:#000}
#kx-fs.deep .kx-stage{cursor:zoom-out}
#kx-fs.deep .kx-fbar,#kx-fs.deep .kx-nav,#kx-fs.deep .kx-list,#kx-fs.deep .kx-hot{display:none}
#kx-fs.deep.idle{cursor:none}
#kx-fs[data-dir="next"].deep .kx-stage>*{animation:kxnext .38s var(--ease)}
#kx-fs[data-dir="prev"].deep .kx-stage>*{animation:kxprev .38s var(--ease)}
@keyframes kxnext{from{transform:translateY(70px)}}
@keyframes kxprev{from{transform:translateY(-70px)}}
.kx-m{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
.kx-m.hi{opacity:0;transition:opacity .45s}.kx-m.hi.on{opacity:1}
.kx-hq{position:absolute;top:64px;left:50%;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:8px;
padding:6px 14px;border-radius:999px;background:rgba(0,0,0,.55);backdrop-filter:blur(10px);font-size:12px;pointer-events:none}
.kx-stage video{display:block;width:100%;height:100%;object-fit:contain;background:#000}
.kx-fbar{position:absolute;top:0;left:0;right:0;display:flex;align-items:center;gap:10px;padding:12px 16px;
background:linear-gradient(180deg,#000b,transparent);z-index:2;transition:opacity .4s var(--ease)}
.kx-fbar a{color:var(--fg);text-decoration:none;padding:6px 12px;border-radius:999px;background:var(--sur);font-size:13px;transition:.25s}
.kx-fbar a:hover{background:var(--sur2)}
.kx-nav{position:absolute;top:50%;transform:translateY(-50%);width:46px;height:46px;border-radius:50%;background:var(--sur);
font-size:22px;display:grid;place-items:center;z-index:2;transition:.25s var(--ease)}
.kx-nav:hover{background:var(--sur2);transform:translateY(-50%) scale(1.08)}
.kx-nav.p{left:12px}.kx-nav.n{right:42px}
#kx-fs.idle .kx-fbar,#kx-fs.idle .kx-nav{opacity:0;pointer-events:none}
#kx-fs.idle{cursor:none}
.kx-tagmenu{position:fixed;z-index:30;min-width:180px;padding:6px;border:1px solid #ffffff1a;border-radius:14px;
background:#1b1b26;box-shadow:0 12px 36px #000b}
.kx-tagmenu button{display:block;width:100%;padding:9px 10px;border-radius:9px;text-align:left;font-size:13px}
.kx-tagmenu button:hover{background:var(--sur2)}
.kx-hot{position:absolute;top:0;right:0;bottom:0;width:30px;z-index:3}
.kx-list{position:absolute;top:0;right:0;bottom:0;width:250px;z-index:4;padding:56px 14px 14px;overflow:auto;
display:flex;flex-direction:column;gap:10px;background:linear-gradient(270deg,rgba(14,14,21,.97) 70%,rgba(14,14,21,.8));
backdrop-filter:blur(16px);transform:translateX(105%);transition:transform .4s var(--ease);box-shadow:-14px 0 50px #000a}
.kx-list.open{transform:none}
.kx-list .t{font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:var(--mut);margin-bottom:2px}
.kx-li{position:relative;flex:none;border-radius:16px;overflow:hidden;background:#1a1a24;opacity:.72;transition:.25s var(--ease)}
.kx-li:hover{opacity:1;transform:scale(1.03)}
.kx-li.cur{opacity:1;transform:scale(1.04);box-shadow:0 0 0 3px var(--acc),0 10px 30px #000a}
.kx-li img{display:block;width:100%;height:100%;object-fit:cover;object-position:top}
.kx-li span{position:absolute;left:8px;bottom:8px;padding:2px 8px;border-radius:999px;background:#0008;font-size:11px}
.kx-listbtn{display:none}
/* возрастное подтверждение */
#kx-gate{position:absolute;inset:0;z-index:20;display:grid;place-items:center;background:rgba(8,8,13,.9);backdrop-filter:blur(24px)}
.kx-gatebox{max-width:420px;margin:20px;padding:30px;border-radius:28px;background:linear-gradient(160deg,#1c1830,#12121a);
text-align:center;box-shadow:0 30px 80px #000c;display:flex;flex-direction:column;gap:14px}
.kx-gatebox h2{margin:0;font-size:22px}
.kx-gatebox p{margin:0;color:var(--mut)}
#kx-back{position:fixed;left:14px;bottom:14px;z-index:2147483001;display:none;padding:10px 16px;border-radius:999px;border:0;
background:linear-gradient(90deg,#8b5cf6,#ec4899);color:#fff;font:13px system-ui;cursor:pointer;box-shadow:0 10px 30px #0008}
#kx-back.on{display:block}
@media (max-width:900px){
#kx-root{flex-direction:column}
.kx-side{width:100%;max-height:50%;padding:12px;gap:10px}
.kx-nav{display:none}
.kx-listbtn{display:grid}
}`;
/* ====================== каркас ====================== */
const style = el('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = el('div');
root.id = 'kx-root';
root.innerHTML = `
<aside class="kx-side">
<div class="kx-brand">
<span class="kx-logo">kurosearch</span><span class="kx-sp"></span>
<select class="kx-lang" id="kx-lang" data-i18n-title="languageLabel" data-i18n-aria-label="languageLabel"><option value="ru">Русский</option><option value="en">English</option></select>
<button class="kx-ico" id="kx-orig" data-i18n-title="origTitle">⇄</button>
</div>
<div class="kx-search"><span>⌕</span>
<input id="kx-q" data-i18n-placeholder="searchPlaceholder" autocomplete="off" spellcheck="false"></div>
<div class="kx-active" id="kx-active"></div>
<div class="kx-body">
<div class="kx-tagpane" id="kx-tagpane"></div>
<section class="kx-panel" id="kx-favpanel"></section>
<section class="kx-panel" id="kx-filters"><div class="kx-fin">
<div class="kx-row"><label data-i18n="sortLabel"></label>
<select id="kx-sort"><option value="id" data-i18n="newest"></option><option value="score" data-i18n="highestScore"></option><option value="updated" data-i18n="recentlyUpdated"></option><option value="random" data-i18n="random"></option></select>
<button class="kx-pill" id="kx-dir" data-i18n-title="sortDirection">↓</button></div>
<div class="kx-row"><label data-i18n="mediaLabel"></label><div class="kx-seg" id="kx-media">
<button data-mode="all" data-i18n="all"></button><button data-mode="images" data-i18n="images"></button><button data-mode="animated" data-i18n="animated"></button></div></div>
<div><div class="kx-lbl" data-i18n="blockedLabel"></div>
<div class="kx-blocked-list" id="kx-blocked-list"><input id="kx-blocked-input" data-i18n-placeholder="blockedPlaceholder"></div></div>
<div><div class="kx-lbl" data-i18n="requiredLabel"></div><textarea id="kx-required" placeholder="sound animated"></textarea></div>
<div class="kx-row"><button class="kx-pill" id="kx-comics" data-i18n="hideComics"></button><span class="kx-sp"></span><button class="kx-pill" id="kx-reset" data-i18n="reset"></button></div>
</div></section>
</div>
<nav class="kx-tabs">
<button id="kx-tab-fav" data-i18n="favoritesTab"></button>
<button id="kx-tab-filters"><span data-i18n="filtersTab"></span><span class="kx-badge" id="kx-badge"></span></button>
</nav>
</aside>
<main class="kx-main">
<div class="kx-head">
<div class="kx-seg kx-feedseg" id="kx-feed"><button data-v="all" data-i18n="all"></button><button data-v="foryou" data-i18n="forYou"></button></div>
<div class="kx-stat" id="kx-stat"></div><span class="kx-sp"></span>
<button class="kx-pill" id="kx-layout"></button>
</div>
<div class="kx-scroll" id="kx-scroll"><div id="kx-note"></div><div class="kx-grid" id="kx-grid"></div><div id="kx-msg"></div></div>
<div id="kx-fs">
<div class="kx-fbar"><button class="kx-ico" id="kx-fclose" data-i18n-title="closeViewer">✕</button>
<span id="kx-finfo" class="kx-stat"></span><span class="kx-sp"></span><span id="kx-flinks" class="kx-row"></span><button class="kx-ico" id="kx-fdeep" data-i18n-title="fullscreen">⛶</button>
<button class="kx-ico kx-listbtn" id="kx-flistbtn">▤</button></div>
<div class="kx-stage" id="kx-stage"></div>
<button class="kx-nav p" id="kx-fprev">‹</button><button class="kx-nav n" id="kx-fnext">›</button>
<div class="kx-hot" id="kx-hot"></div>
<aside class="kx-list" id="kx-list"></aside>
</div>
</main>
<div id="kx-gate" style="display:none"><div class="kx-gatebox">
<h2 data-i18n="gateTitle"></h2>
<p data-i18n="gateCopy"></p>
<button class="kx-pill on" id="kx-gate-ok" data-i18n="ageConfirm"></button>
<button class="kx-pill" id="kx-gate-no" data-i18n="leave"></button></div></div>
<div class="kx-tagmenu" id="kx-tagmenu" hidden></div>`;
document.body.appendChild(root);
const backBtn = el('button');
backBtn.id = 'kx-back';
document.body.appendChild(backBtn);
const $ = (id) => root.querySelector('#' + id);
const qEl = $('kx-q'), activeEl = $('kx-active'), tagPane = $('kx-tagpane');
const scrollEl = $('kx-scroll'), grid = $('kx-grid'), msgEl = $('kx-msg'), statEl = $('kx-stat');
const fsEl = $('kx-fs'), stage = $('kx-stage'), listEl = $('kx-list');
const tagMenu = $('kx-tagmenu');
const sideEl = root.querySelector('.kx-side'), favPanel = $('kx-favpanel');
function applyStaticLanguage() {
root.lang = cfg.lang;
$('kx-lang').value = cfg.lang;
root.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
root.querySelectorAll('[data-i18n-title]').forEach((node) => { node.title = t(node.dataset.i18nTitle); });
root.querySelectorAll('[data-i18n-aria-label]').forEach((node) => { node.setAttribute('aria-label', t(node.dataset.i18nAriaLabel)); });
root.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
backBtn.textContent = t('backToStudio');
}
/* ====================== поиск ====================== */
async function fetchCount(gen, ctrl, query) {
try {
const r = await api(`/count?tags=${query}`, ctrl.signal);
const m = (await r.text()).match(/count="(\d+)"/);
if (gen === S.gen && m) { S.count = Number(m[1]); renderStat(); }
} catch {}
}
// «Для вас»: несколько потоков (избранные авторы, любимые персонажи/лицензии, обычная выдача как запас)
function buildStreams() {
const base = { q: buildQuery(), pid: 0, ended: false, kind: 'base' };
if (cfg.feed !== 'foryou' || cfg.tags.some((t) => t.m === '~')) return [base];
wantTypes(Object.entries(cfg.views).sort((a, b) => b[1] - a[1]).slice(0, 60).map((e) => e[0]));
const prof = profile();
const streams = [];
for (let i = 0; i < prof.artists.length && streams.length < 3; i += 8) {
streams.push({ q: buildQuery([orGroup(prof.artists.slice(i, i + 8))]), pid: 0, ended: false, kind: 'fav' });
}
if (prof.disc.length) streams.push({ q: buildQuery([orGroup(prof.disc)]), pid: 0, ended: false, kind: 'disc' });
if (!streams.length) { S.noProfile = true; return [base]; }
base.kind = 'fallback';
streams.push(base);
return streams;
}
function newSearch() {
if (S.fs >= 0) closeFS();
S.gen++;
if (S.ctrl) S.ctrl.abort();
S.ctrl = new AbortController();
Object.assign(S, { streams: [], noProfile: false, ids: new Set(), raw: [], view: [], loading: false, ended: false, count: null, err: null });
S.tagFreq.clear();
resetGrid();
scrollEl.scrollTop = 0;
if (cfg.ageOk) S.streams = buildStreams();
renderStat(); renderMsg(); renderNote(); renderTagPane();
if (!cfg.ageOk) return;
if (S.streams.length === 1) fetchCount(S.gen, S.ctrl, S.streams[0].q);
loadMore();
}
const scheduleSearch = debounce(newSearch, 280);
function renderNote() {
const n = $('kx-note');
n.replaceChildren();
if (cfg.ageOk && cfg.feed === 'foryou' && S.noProfile) {
n.append(el('div', 'kx-note', t('forYouFallback')));
}
}
async function loadMore() {
if (S.loading || S.ended || !cfg.ageOk || !S.streams.length) return;
const gen = S.gen, ctrl = S.ctrl;
S.loading = true; S.err = null;
renderStat();
let added = 0, rounds = 0;
try {
while (added < MIN_NEW && !S.ended && rounds < MAX_PAGES_PER_LOAD) {
let live = S.streams.filter((st) => !st.ended && st.kind !== 'fallback');
if (!live.length) live = S.streams.filter((st) => !st.ended);
if (!live.length) { S.ended = true; break; }
let lastErr = null;
const pages = await Promise.all(live.map(async (st) => {
try {
const r = await api(`/posts?limit=${LIMIT}&pid=${st.pid}&tags=${st.q}`, ctrl.signal);
return { st, data: await r.json() };
} catch (e) {
if (e && e.name === 'AbortError') throw e;
lastErr = e;
return { st, data: [], failed: true };
}
}));
if (gen !== S.gen) return;
if (pages.every((x) => x.failed)) throw lastErr;
rounds++;
const batch = [];
for (const { st, data, failed } of pages) {
st.pid++;
if (failed || !Array.isArray(data) || data.length === 0) { st.ended = true; continue; }
for (const rawPost of data) {
const id = Number(rawPost.id);
if (S.ids.has(id)) continue;
S.ids.add(id);
batch.push(normPost(rawPost));
}
}
if (S.streams.every((st) => st.ended)) S.ended = true;
if (S.streams.length > 1) { // лучшие по совпадению с вашими вкусами — выше
for (const q of batch) q.aff = postScore(q);
batch.sort((a, b) => b.aff - a.aff || b.id - a.id);
}
for (const q of batch) {
S.raw.push(q);
for (const t of q.tags) S.tagFreq.set(t, (S.tagFreq.get(t) || 0) + 1);
if (keep(q)) { S.view.push(q); added++; }
}
}
} catch (e) {
if (e && e.name === 'AbortError') return;
S.err = e;
}
if (gen !== S.gen) return;
S.loading = false;
appendCards();
renderStat(); renderMsg(); renderTagPane(true);
if (S.fs >= 0 && listEl.querySelector('.kx-lend')) extendFsList(40);
if (added > 0) ensureFilled();
}
function ensureFilled() {
if (root.classList.contains('away') || S.ended || S.loading) return;
if (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 700) loadMore();
}
scrollEl.addEventListener('scroll', ensureFilled, { passive: true });
function refilter() {
rebuildKeep();
const curId = S.fs >= 0 ? S.view[S.fs]?.id : null;
S.view = S.raw.filter(keep);
resetGrid();
appendCards();
if (S.fs >= 0) { // открыт просмотр: удерживаем текущий арт или переходим к следующему подходящему
if (!S.view.length) closeFS();
else {
let ni = S.view.findIndex((q) => q.id === curId);
if (ni < 0) {
const ri = S.raw.findIndex((q) => q.id === curId);
const nxt = S.raw.slice(ri + 1).find(keep);
ni = nxt ? S.view.findIndex((q) => q.id === nxt.id) : S.view.length - 1;
}
S.fs = ni; buildFsList(); markFsCurrent(true); renderFS();
}
}
renderStat(); renderMsg();
updateBadge();
if (S.view.length < MIN_NEW && !S.ended) loadMore(); else ensureFilled();
}
const scheduleRefilter = debounce(refilter, 350);
/* ====================== сетка / лента ====================== */
const colCount = () => (cfg.layout === 'feed' ? 1 : innerWidth < 720 ? 2 : 3);
const flipIO = 'IntersectionObserver' in window
? new IntersectionObserver((entries) => {
for (const e of entries) (e.isIntersecting ? startFlip : stopFlip)(e.target);
}, { root: scrollEl, rootMargin: '300px' })
: null;
function startFlip(card) {
if (card._v) return;
const p = S.view[Number(card.dataset.i)];
if (!p) return;
const v = document.createElement('video');
v.muted = true; v.playsInline = true; v.preload = 'metadata'; v.src = videoSrc(p);
v.addEventListener('loadedmetadata', () => {
const d = v.duration;
if (!isFinite(d) || d <= 0) return;
const times = Array.from({ length: FLIP_FRAMES }, (_, k) => (d * (k + 0.5)) / FLIP_FRAMES);
let k = 0;
v.currentTime = times[0];
card._t = setInterval(() => { k = (k + 1) % FLIP_FRAMES; v.currentTime = times[k]; }, FLIP_MS);
});
v.addEventListener('seeked', () => v.classList.add('on'), { once: true });
card.append(v);
card._v = v;
}
function stopFlip(card) {
clearInterval(card._t);
if (card._v) { card._v.removeAttribute('src'); card._v.load(); card._v.remove(); card._v = null; }
}
function makeCard(p, i) {
const c = el('div', 'kx-card');
c.dataset.i = i;
c.tabIndex = 0;
c.style.aspectRatio = String(ratioOf(p));
const img = el('img');
img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = thumbSrc(p);
c.append(img);
if (p.type !== 'image') c.append(el('span', 'kx-tg', p.type === 'video' ? `▶ ${t('video')}` : 'GIF'));
c.append(el('span', 'kx-sc', '★ ' + p.score));
if (p.type === 'video' && flipIO) flipIO.observe(c);
return c;
}
function resetGrid() {
if (flipIO) grid.querySelectorAll('.kx-card').forEach((c) => { flipIO.unobserve(c); stopFlip(c); });
grid.replaceChildren();
const n = colCount();
grid.className = 'kx-grid ' + (n === 1 ? 'feed' : 'multi');
S.colH = Array(n).fill(0);
S.cols = Array.from({ length: n }, () => { const c = el('div', 'kx-col'); grid.append(c); return c; });
S.rendered = 0;
}
function appendCards() {
for (; S.rendered < S.view.length; S.rendered++) {
const p = S.view[S.rendered];
let k = 0;
for (let j = 1; j < S.colH.length; j++) if (S.colH[j] < S.colH[k]) k = j;
S.cols[k].append(makeCard(p, S.rendered));
S.colH[k] += 1 / ratioOf(p) + 0.04;
}
}
grid.addEventListener('click', (e) => {
const c = e.target.closest('.kx-card');
if (c) openFS(Number(c.dataset.i));
});
grid.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { const c = e.target.closest('.kx-card'); if (c) openFS(Number(c.dataset.i)); }
});
addEventListener('resize', debounce(() => {
if (colCount() !== S.cols.length) { resetGrid(); appendCards(); }
}, 200));
function renderStat() {
statEl.replaceChildren();
if (S.loading) statEl.append(el('span', 'kx-spin'));
const parts = [];
if (S.streams.length > 1) parts.push(t('currentTasteFeed'));
if (S.count != null) parts.push(S.count.toLocaleString(cfg.lang === 'ru' ? 'ru-RU' : 'en-US') + ' ' + t('posts'));
parts.push(t('shown') + ' ' + S.view.length + (S.raw.length !== S.view.length ? ` (${t('hidden')} ${S.raw.length - S.view.length})` : ''));
statEl.append(el('span', null, parts.join(' · ')));
$('kx-layout').textContent = cfg.layout === 'feed' ? t('grid') : t('feed');
}
function renderMsg() {
msgEl.replaceChildren();
if (!cfg.ageOk) return;
if (S.err) {
const d = el('div', 'kx-msg', t('loadFailed') + (S.err.message || S.err));
const b = el('button', 'kx-pill', t('retry'));
b.onclick = () => { S.err = null; loadMore(); };
d.append(el('br'), b);
msgEl.append(d);
} else if (!S.loading && S.view.length === 0 && (S.ended || S.raw.length)) {
msgEl.append(el('div', 'kx-msg', t('nothingFound')));
} else if (S.ended && S.view.length) {
msgEl.append(el('div', 'kx-msg', t('endOfResults')));
}
}
$('kx-layout').onclick = () => {
cfg.layout = cfg.layout === 'feed' ? 'grid' : 'feed';
saveCfg();
resetGrid(); appendCards(); renderStat();
ensureFilled();
};
/* ====================== теги: поиск и подсказки ====================== */
function parseInput(v) {
let mod = '+', t = v.trimStart();
if (t[0] === '-') { mod = '-'; t = t.slice(1); }
else if (t[0] === '~') { mod = '~'; t = t.slice(1); }
else if (t[0] === '+') t = t.slice(1);
return { mod, term: normName(t) };
}
function addTag(name, mod = '+') {
name = normName(name);
if (!name) return;
if (S.fs >= 0) closeFS();
const ex = cfg.tags.find((t) => t.n === name);
if (ex) ex.m = mod; else cfg.tags.push({ m: mod, n: name });
saveCfg();
qEl.value = '';
S.sug = []; S.hl = -1;
renderActive(); renderTagPane();
newSearch();
}
function removeTag(name) {
cfg.tags = cfg.tags.filter((t) => t.n !== name);
saveCfg(); renderActive(); renderTagPane(); newSearch();
}
function cycleTag(name) {
const t = cfg.tags.find((x) => x.n === name);
if (!t) return;
t.m = t.m === '+' ? '-' : t.m === '-' ? '~' : '+';
saveCfg(); renderActive(); newSearch();
}
const isFav = (n) => cfg.favorites.includes(n);
function toggleFav(name) {
const i = cfg.favorites.indexOf(name);
if (i >= 0) cfg.favorites.splice(i, 1); else cfg.favorites.push(name);
saveCfg();
renderActive(); renderTagPane();
if (tab === 'fav') renderFavPanel();
}
function showTagMenu(name, e) {
e.preventDefault();
tagMenu.replaceChildren();
const option = (text, action) => {
const b = el('button', null, text);
b.onclick = () => { closeTagMenu(); action(); };
tagMenu.append(b);
};
option(isFav(name) ? t('removeFavorite') : t('addFavorite'), () => toggleFav(name));
const blocked = splitTags(cfg.blocked);
const isBlocked = blocked.includes(name);
option(isBlocked ? t('unhideTag') : t('hideTag'), () => {
setBlockedTags(isBlocked ? blocked.filter((n) => n !== name) : [...blocked, name]);
});
tagMenu.hidden = false;
const x = Math.min(e.clientX, innerWidth - tagMenu.offsetWidth - 8);
const y = Math.min(e.clientY, innerHeight - tagMenu.offsetHeight - 8);
tagMenu.style.left = Math.max(8, x) + 'px';
tagMenu.style.top = Math.max(8, y) + 'px';
}
function closeTagMenu() {
tagMenu.hidden = true;
}
root.addEventListener('contextmenu', (e) => {
const tag = e.target.closest('.kx-tag,.kx-chip,.kx-sug,.kx-ftag');
if (!tag) { closeTagMenu(); return; }
const name = tag.dataset.n || tag.querySelector('.n')?.textContent?.replace(/^[−~] /, '');
if (name) showTagMenu(normName(name), e);
});
root.addEventListener('click', (e) => {
if (!e.target.closest('#kx-tagmenu')) closeTagMenu();
});
document.addEventListener('pointerdown', (e) => {
if (!tagMenu.hidden && !tagMenu.contains(e.target)) closeTagMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !tagMenu.hidden) {
e.preventDefault();
e.stopPropagation();
closeTagMenu();
}
}, true);
function setBlockedTags(tags) {
cfg.blocked = [...new Set(tags.map((t) => t.toLowerCase()))].join(' ');
clientChange();
}
function renderBlockedTags() {
const wrap = $('kx-blocked-list');
const input = $('kx-blocked-input');
wrap.querySelectorAll('.kx-blocked-tag').forEach((tag) => tag.remove());
splitTags(cfg.blocked).forEach((name) => {
const chip = el('button', 'kx-blocked-tag');
chip.type = 'button';
chip.title = t('removeBlocked');
chip.append(el('span', null, name), el('b', null, '×'));
chip.onclick = () => setBlockedTags(splitTags(cfg.blocked).filter((t) => t !== name));
wrap.insertBefore(chip, input);
});
}
function commitBlockedInput() {
const input = $('kx-blocked-input');
const incoming = splitTags(input.value);
if (!incoming.length) return;
setBlockedTags([...splitTags(cfg.blocked), ...incoming]);
input.value = '';
}
function renderActive() {
activeEl.replaceChildren();
for (const tag of cfg.tags) {
const chip = el('span', 'kx-chip ' + (tag.m === '-' ? 'm-minus' : tag.m === '~' ? 'm-any' : '') + typeCls(tag.n));
chip.dataset.n = tag.n;
chip.title = t('cycleTag');
chip.append(el('span', 'n', (tag.m === '-' ? '− ' : tag.m === '~' ? '~ ' : '') + tag.n));
const x = el('button', 'x', '✕');
x.onclick = (e) => { e.stopPropagation(); removeTag(tag.n); };
chip.append(x);
chip.onclick = () => cycleTag(tag.n);
activeEl.append(chip);
}
wantTypes(cfg.tags.map((t) => t.n));
}
function tagChip(name, count) {
const b = el('span', 'kx-tag' + typeCls(name));
b.dataset.n = name;
const nm = el('span', 'n', name);
if (cfg.favorites.includes(name)) nm.append(el('span', 'kx-favmark', ' ★'));
b.append(nm);
if (count != null) b.append(el('span', 'c', String(count)));
if (cfg.tags.some((t) => t.n === name)) b.classList.add('used');
const ex = el('button', 'ex', '−');
ex.title = t('exclude');
ex.onclick = (e) => { e.stopPropagation(); addTag(name, '-'); };
b.append(ex);
b.onclick = () => addTag(name, '+');
return b;
}
function renderTagPane(soft) {
const { term } = parseInput(qEl.value);
if (term.length >= 2) { renderSuggestions(); return; }
const mode = S.fs >= 0 ? 'post' : 'default';
if (soft && tagPane.dataset.mode === mode && tagPane.matches(':hover')) return;
tagPane.dataset.mode = mode;
tagPane.replaceChildren();
if (S.fs >= 0) { renderPostTags(); return; }
const used = new Set(cfg.tags.map((t) => t.n));
const favSet = new Set(cfg.favorites);
const blocked = splitTags(cfg.blocked).map(toRe);
const rel = [...S.tagFreq.entries()]
.filter(([n]) => !used.has(n) && !favSet.has(n) && !blocked.some((re) => re.test(n)))
.sort((a, b) => b[1] - a[1]).slice(0, 36)
.sort((a, b) => typeRank(a[0]) - typeRank(b[0]) || b[1] - a[1]);
wantTypes(rel.map((r) => r[0]));
tagPane.append(el('div', 'kx-sec', rel.length ? t('currentResults') : t('enterTag')));
if (rel.length) {
const cl = el('div', 'kx-cloud');
rel.forEach(([n, c]) => cl.append(tagChip(n, c)));
tagPane.append(cl);
} else {
tagPane.append(el('div', 'kx-empty', t('tagHints')));
}
}
// теги открытого арта: избранные первыми, дальше по важности (авторы → персонажи → лицензии → остальные)
function renderPostTags() {
const p = S.view[S.fs];
if (!p) return;
const tags = [...new Set(p.tags)].sort((a, b) => Number(isFav(b)) - Number(isFav(a)) || byImportance(a, b));
tagPane.append(el('div', 'kx-sec', `${t('postTags')} · ${tags.length}`));
const cl = el('div', 'kx-cloud');
tags.forEach((t) => cl.append(tagChip(t)));
tagPane.append(cl);
tagPane.append(el('div', 'kx-empty', t('postTagHelp')));
wantTypes(tags, true);
}
/* ====================== вкладки «Избранные» и «Фильтры» ====================== */
function setTab(t) {
tab = t;
sideEl.dataset.tab = t;
$('kx-tab-fav').classList.toggle('on', t === 'fav');
$('kx-tab-filters').classList.toggle('on', t === 'filters');
if (t === 'fav') renderFavPanel();
}
$('kx-tab-fav').onclick = () => setTab(tab === 'fav' ? '' : 'fav');
$('kx-tab-filters').onclick = () => setTab(tab === 'filters' ? '' : 'filters');
function renderFavPanel() {
favPanel.replaceChildren();
const favs = cfg.favorites;
wantTypes(favs, true);
favPanel.append(el('div', 'kx-sec', t('favoritesTitle')));
if (!favs.length) {
favPanel.append(el('div', 'kx-empty', t('emptyFavorites')));
} else {
const groups = [['artist', t('artists')], ['character', t('characters')], ['copyright', t('copyrights')], ['other', t('others')]];
for (const [key, title] of groups) {
const list = favs.filter((f) => (['artist', 'character', 'copyright'].includes(typeCache[f]) ? typeCache[f] : 'other') === key).sort(byImportance);
if (!list.length) continue;
const box = el('div');
box.append(el('div', 'kx-sec', title));
const cl = el('div', 'kx-cloud');
list.forEach((f) => cl.append(tagChip(f)));
box.append(cl);
favPanel.append(box);
}
}
const likes = interestTags(14).filter((n) => !isFav(n));
if (likes.length) {
const box = el('div');
box.append(el('div', 'kx-sec', t('interests')));
const cl = el('div', 'kx-cloud');
likes.forEach((n) => cl.append(tagChip(n)));
box.append(cl);
box.append(el('div', 'kx-empty', t('interestsHelp')));
favPanel.append(box);
}
}
// подсказки: сначала подходящие избранные, затем ответ API
function sugView() {
const { term } = parseInput(qEl.value);
const favs = cfg.favorites.filter((f) => term.length >= 2 && f.includes(term)).sort(byImportance)
.map((name) => ({ name, count: null }));
const seen = new Set(favs.map((f) => f.name));
return [...favs, ...S.sug.filter((x) => !seen.has(x.name))];
}
function renderSuggestions() {
tagPane.dataset.mode = 'sug';
tagPane.replaceChildren();
const { mod, term } = parseInput(qEl.value);
tagPane.append(el('div', 'kx-sec', t('suggestions')));
const list = sugView();
if (!list.length) {
tagPane.append(el('div', 'kx-empty', t('enterToAdd').replace('{term}', term)));
return;
}
list.forEach((s, i) => {
const row = el('div', 'kx-sug' + (i === S.hl ? ' hl' : '') + typeCls(s.name));
row.dataset.n = s.name;
row.append(el('span', 'n', s.name), el('span', 'c', s.count != null ? String(s.count) : ''));
const minus = el('button', 'b', '−'); minus.title = t('exclude');
const any = el('button', 'b', '~'); any.title = t('anyOf');
minus.onclick = (e) => { e.stopPropagation(); addTag(s.name, '-'); };
any.onclick = (e) => { e.stopPropagation(); addTag(s.name, '~'); };
row.append(minus, any);
row.onclick = () => addTag(s.name, mod);
tagPane.append(row);
});
}
function paintSuggestions() {
tagPane.querySelectorAll('.kx-sug[data-n]').forEach((row) => {
row.classList.remove('t-artist', 't-character', 't-copyright', 't-metadata');
const t = typeOf(row.dataset.n);
if (t && t !== 'general') row.classList.add('t-' + t);
});
}
let sugCtrl = null;
const fetchSuggest = debounce(async () => {
const { term } = parseInput(qEl.value);
if (term.length < 2) return;
if (sugCtrl) sugCtrl.abort();
sugCtrl = new AbortController();
try {
const r = await fetch(`${AUTOCOMPLETE_URL}?q=${encodeURIComponent(term)}`, { signal: sugCtrl.signal });
const json = await r.json();
if (parseInput(qEl.value).term !== term) return;
S.sug = Array.isArray(json)
? json.map((x) => {
const label = String(x.label || '');
const m = label.match(/\((\d+)\)\s*$/);
return { name: decodeEntities(x.value), count: m ? Number(m[1]) : null };
})
: [];
S.hl = -1;
renderSuggestions();
wantTypes(S.sug.slice(0, 12).map((x) => x.name), true);
} catch (e) {
if (e && e.name === 'AbortError') return;
S.sug = []; renderSuggestions();
}
}, 160);
qEl.addEventListener('input', () => {
if (tab && parseInput(qEl.value).term.length >= 2) setTab('');
const { term } = parseInput(qEl.value);
if (term.length < 2) { S.sug = []; S.hl = -1; renderTagPane(); }
else { S.hl = -1; if (cfg.favorites.some((f) => f.includes(term))) renderSuggestions(); fetchSuggest(); }
});
qEl.addEventListener('keydown', (e) => {
const { mod, term } = parseInput(qEl.value);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const n = sugView().length;
if (!n) return;
e.preventDefault();
S.hl = clamp(S.hl + (e.key === 'ArrowDown' ? 1 : -1), 0, n - 1);
renderSuggestions();
tagPane.querySelector('.kx-sug.hl')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
const picked = sugView()[S.hl];
if (S.hl >= 0 && picked) addTag(picked.name, mod);
else if (term) addTag(term, mod);
} else if (e.key === 'Backspace' && !qEl.value && cfg.tags.length) {
removeTag(cfg.tags[cfg.tags.length - 1].n);
} else if (e.key === 'Escape') {
qEl.value = ''; S.sug = []; renderTagPane(); qEl.blur();
}
});
/* ====================== панель фильтров ====================== */
function activeFilterCount() {
let n = 0;
if (cfg.sortProp !== 'id' || cfg.sortDir !== 'desc') n++;
if (!cfg.showImage || !cfg.showGif || !cfg.showVideo) n++;
if (cfg.blocked.trim()) n++;
if (cfg.required.trim()) n++;
if (cfg.hideComics) n++;
return n;
}
function updateBadge() {
const n = activeFilterCount(), b = $('kx-badge');
b.textContent = n; b.classList.toggle('on', n > 0);
}
function syncFilterUI() {
$('kx-feed').querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.v === cfg.feed));
$('kx-sort').value = cfg.sortProp;
$('kx-dir').textContent = cfg.sortDir === 'desc' ? '↓' : '↑';
renderBlockedTags();
$('kx-required').value = cfg.required;
$('kx-comics').classList.toggle('on', cfg.hideComics);
const mode = cfg.showImage && cfg.showGif && cfg.showVideo ? 'all' :
cfg.showImage ? 'images' : 'animated';
$('kx-media').querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.mode === mode));
updateBadge();
}
$('kx-lang').onchange = (e) => {
cfg.lang = e.target.value;
saveCfg();
applyStaticLanguage();
syncFilterUI();
renderActive();
renderTagPane();
if (tab === 'fav') renderFavPanel();
renderStat();
renderMsg();
renderNote();
if (S.fs >= 0) { renderFS(); buildFsList(); }
};
const serverChange = () => { saveCfg(); syncFilterUI(); scheduleSearch(); };
const clientChange = () => { saveCfg(); syncFilterUI(); scheduleRefilter(); renderTagPane(true); };
$('kx-feed').onclick = (e) => {
const b = e.target.closest('button');
if (!b || b.dataset.v === cfg.feed) return;
cfg.feed = b.dataset.v; saveCfg(); syncFilterUI(); newSearch();
};
$('kx-sort').onchange = (e) => { cfg.sortProp = e.target.value; serverChange(); };
$('kx-dir').onclick = () => { cfg.sortDir = cfg.sortDir === 'desc' ? 'asc' : 'desc'; serverChange(); };
$('kx-media').onclick = (e) => {
const b = e.target.closest('button'); if (!b) return;
cfg.showImage = b.dataset.mode !== 'animated';
cfg.showGif = b.dataset.mode !== 'images';
cfg.showVideo = b.dataset.mode !== 'images';
clientChange();
};
$('kx-blocked-input').onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === ',') {
e.preventDefault();
commitBlockedInput();
} else if (e.key === 'Backspace' && !e.target.value) {
const tags = splitTags(cfg.blocked);
if (tags.length) setBlockedTags(tags.slice(0, -1));
}
};
$('kx-blocked-input').onblur = commitBlockedInput;
$('kx-required').oninput = (e) => { cfg.required = e.target.value; clientChange(); };
$('kx-comics').onclick = () => { cfg.hideComics = !cfg.hideComics; clientChange(); };
$('kx-reset').onclick = () => {
const keepStuff = { tags: cfg.tags, favorites: cfg.favorites, views: cfg.views, feed: cfg.feed, layout: cfg.layout, lang: cfg.lang, ageOk: cfg.ageOk };
cfg = { ...DEFAULTS, ...keepStuff };
saveCfg(); syncFilterUI(); rebuildKeep(); newSearch();
};
/* ====================== полноэкранный просмотр ====================== */
function clearStage() {
stage.querySelectorAll('video').forEach((v) => { v.pause(); v.removeAttribute('src'); v.load(); });
stage.replaceChildren();
}
let fsBase = 0, fsListEnd = 0, idleTimer = null, viewTimer = null, deep = false;
function wakeFS() {
fsEl.classList.remove('idle');
clearTimeout(idleTimer);
idleTimer = setTimeout(function tick() {
if (S.fs < 0) return;
if (root.querySelector('.kx-fbar:hover,.kx-list.open')) { idleTimer = setTimeout(tick, 1500); return; }
fsEl.classList.add('idle');
}, 2500);
}
fsEl.addEventListener('mousemove', wakeFS);
fsEl.addEventListener('touchstart', wakeFS, { passive: true });
function openFS(i, opts = {}) {
if (i < 0 || i >= S.view.length) return;
const wasOpen = S.fs >= 0;
S.fs = i;
if (opts.dir) fsEl.dataset.dir = opts.dir; else delete fsEl.dataset.dir;
if (!wasOpen && tab) setTab('');
fsEl.classList.add('open');
if (!wasOpen || i < fsBase) buildFsList();
else while (i >= fsListEnd && fsListEnd < S.view.length) extendFsList(40);
markFsCurrent(!opts.fromList);
renderFS();
wakeFS();
}
function closeFS() {
const i = S.fs;
S.fs = -1;
clearTimeout(idleTimer); clearTimeout(viewTimer);
setDeep(false);
fsEl.classList.remove('open', 'idle');
renderTagPane();
listEl.classList.remove('open');
clearStage();
grid.querySelector(`.kx-card[data-i="${i}"]`)?.scrollIntoView({ block: 'center', behavior: 'instant' });
}
function stepFS(d) {
const n = S.fs + d;
if (n < 0) return;
const dir = d > 0 ? 'next' : 'prev';
if (n >= S.view.length) {
if (!S.ended) loadMore().then(() => { if (S.fs >= 0 && S.view.length > n) openFS(n, { dir }); });
return;
}
openFS(n, { dir });
}
// «фулл-фулл экран»: без интерфейса, только прокрутка артов
function setDeep(on) {
if (on === deep) return;
deep = on;
fsEl.classList.toggle('deep', on);
const v = stage.querySelector('video');
if (v) v.controls = !on;
try {
if (on) { const r = root.requestFullscreen && root.requestFullscreen(); if (r && r.catch) r.catch(() => {}); }
else if (document.fullscreenElement) { const r = document.exitFullscreen(); if (r && r.catch) r.catch(() => {}); }
} catch {}
wakeFS();
}
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && deep) {
deep = false; fsEl.classList.remove('deep');
const v = stage.querySelector('video'); if (v) v.controls = true;
}
});
stage.addEventListener('click', () => {
const p = S.view[S.fs];
if (!p) return;
if (p.type === 'video') { // у видео клик — пауза, двойной клик — режим «фулл-фулл»
if (deep) { const v = stage.querySelector('video'); if (v) v.paused ? v.play() : v.pause(); }
return;
}
setDeep(!deep);
});
stage.addEventListener('dblclick', () => { const p = S.view[S.fs]; if (p && p.type === 'video') setDeep(!deep); });
let wheelLock = 0, touchY0 = null;
fsEl.addEventListener('wheel', (e) => {
if (!deep) return;
e.preventDefault();
if (Date.now() < wheelLock || Math.abs(e.deltaY) < 6) return;
wheelLock = Date.now() + 450;
stepFS(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
fsEl.addEventListener('touchstart', (e) => { touchY0 = e.touches[0] ? e.touches[0].clientY : null; }, { passive: true });
fsEl.addEventListener('touchend', (e) => {
if (!deep || touchY0 == null) return;
const dy = touchY0 - (e.changedTouches[0] ? e.changedTouches[0].clientY : touchY0);
touchY0 = null;
if (Math.abs(dy) > 70) stepFS(dy > 0 ? 1 : -1);
}, { passive: true });
function renderFS() {
const p = S.view[S.fs];
if (!p) return;
clearStage();
fsEl.classList.toggle('vid', p.type === 'video');
if (p.type === 'video') {
const v = document.createElement('video');
v.controls = !deep; v.loop = true; v.playsInline = true; v.src = p.file; v.poster = p.prev;
stage.append(v);
v.play().catch(() => { v.muted = true; v.play().catch(() => {}); });
} else if (p.type === 'gif') {
const im = el('img', 'kx-m'); im.src = p.file; im.alt = '';
stage.append(im);
} else {
// сначала быстро показываем превью, поверх плавно проявляется оригинал (file_url)
const lo = el('img', 'kx-m'); lo.src = thumbSrc(p); lo.alt = '';
const hi = el('img', 'kx-m hi'); hi.alt = ''; hi.decoding = 'async'; hi.fetchPriority = 'high';
const hq = el('div', 'kx-hq');
hq.append(el('span', 'kx-spin'), el('span', null, t('loadingOriginal')));
hi.onload = () => { hi.classList.add('on'); hq.remove(); };
hi.onerror = () => { hi.remove(); hq.replaceChildren(el('span', null, t('originalFailed'))); };
hi.src = p.file;
stage.append(lo, hi, hq);
}
$('kx-finfo').textContent = `${S.fs + 1} / ${S.view.length} · #${p.id} · ★ ${p.score} · ${p.rating}`;
const links = $('kx-flinks');
links.replaceChildren();
const mk = (text, href) => {
const a = el('a', null, text); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; links.append(a);
};
mk(t('post'), `https://rule34.xxx/index.php?page=post&s=view&id=${p.id}`);
mk(t('file'), p.file);
if (/^https?:\/\//i.test(p.source)) mk(t('source'), p.source);
renderTagPane();
clearTimeout(viewTimer);
viewTimer = setTimeout(() => recordView(p), 2500); // арт, на который вы смотрели, уточняет ваши вкусы
// предзагрузка оригиналов соседних артов, чтобы листать без ожидания
[S.view[S.fs + 1], S.view[S.fs + 2], S.view[S.fs - 1]].forEach((q) => {
if (q && q.type === 'image') new Image().src = q.file;
});
if (S.view.length - S.fs < 10 && !S.ended) loadMore();
}
/* Список «дальше» строится один раз при открытии и потом только дополняется,
поэтому при клике по элементу он не «уезжает». */
function buildFsList() {
listEl.replaceChildren();
listEl.append(el('div', 't', t('next')));
fsBase = S.fs;
fsListEnd = S.fs;
extendFsList(NEXT_LIST_SIZE);
}
function extendFsList(n = 40) {
listEl.querySelector('.kx-lend')?.remove();
const end = Math.min(S.view.length, fsListEnd + n);
for (let i = fsListEnd; i < end; i++) {
const p = S.view[i];
const li = el('button', 'kx-li');
li.dataset.i = i;
li.style.aspectRatio = String(ratioOf(p));
const im = el('img'); im.loading = 'lazy'; im.alt = ''; im.src = p.prev || thumbSrc(p);
li.append(im);
if (p.type !== 'image') li.append(el('span', null, p.type === 'video' ? '▶' : 'GIF'));
li.onclick = () => openFS(i, { fromList: true });
listEl.append(li);
}
fsListEnd = end;
if (fsListEnd >= S.view.length) listEl.append(el('div', 'kx-empty kx-lend', S.ended ? t('lastPost') : t('loading')));
markFsCurrent(false);
}
function markFsCurrent(scroll) {
listEl.querySelectorAll('.kx-li.cur').forEach((x) => x.classList.remove('cur'));
const cur = listEl.querySelector(`.kx-li[data-i="${S.fs}"]`);
if (cur) { cur.classList.add('cur'); if (scroll) cur.scrollIntoView({ block: 'nearest' }); }
}
$('kx-fclose').onclick = closeFS;
$('kx-fprev').onclick = () => stepFS(-1);
$('kx-fnext').onclick = () => stepFS(1);
$('kx-hot').addEventListener('mouseenter', () => listEl.classList.add('open'));
listEl.addEventListener('mouseleave', () => listEl.classList.remove('open'));
$('kx-flistbtn').onclick = () => listEl.classList.toggle('open');
listEl.addEventListener('scroll', () => {
if (listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight > 400) return;
if (fsListEnd < S.view.length) extendFsList(40); else if (!S.ended) loadMore();
}, { passive: true });
$('kx-fdeep').onclick = () => setDeep(!deep);
document.addEventListener('keydown', (e) => {
if (root.classList.contains('away')) return;
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
if (S.fs >= 0) {
let handled = true;
if (e.key === 'Escape') { if (deep) setDeep(false); else closeFS(); }
else if (e.key === 'ArrowRight') stepFS(1);
else if (e.key === 'ArrowLeft') stepFS(-1);
else if (deep && (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ')) stepFS(1);
else if (deep && (e.key === 'ArrowUp' || e.key === 'PageUp')) stepFS(-1);
else handled = false;
if (handled) e.preventDefault();
} else if (e.key === '/' && !typing) {
e.preventDefault(); qEl.focus();
}
if (!typing) e.stopPropagation(); // не отдаём клавиши оригинальному сайту
});
/* ====================== оригинал / возрастной экран ====================== */
$('kx-orig').onclick = () => { root.classList.add('away'); backBtn.classList.add('on'); };
backBtn.onclick = () => { root.classList.remove('away'); backBtn.classList.remove('on'); ensureFilled(); };
const gate = $('kx-gate');
function showGate() { gate.style.display = cfg.ageOk ? 'none' : 'grid'; }
$('kx-gate-ok').onclick = () => { cfg.ageOk = true; saveCfg(); showGate(); newSearch(); };
$('kx-gate-no').onclick = () => { location.href = 'about:blank'; };
/* ====================== старт ====================== */
applyStaticLanguage();
renderActive();
syncFilterUI();
showGate();
setTab('');
newSearch();
})();