为 FC2PPVDB、Supjav 等站点注入磁力链接聚合、高清封面替换、画廊模式、历史记录同步等功能。
// ==UserScript== // @name FC2PPVDB Enhanced // @name:en FC2PPVDB Enhanced // @namespace https://greasyfork.org/zh-CN/scripts/552583-fc2ppvdb-enhanced // @version 2.0.5 // @author Icarusle // @description 为 FC2PPVDB、Supjav 等站点注入磁力链接聚合、高清封面替换、画廊模式、历史记录同步等功能。 // @description:en Inject magnet links, HD covers, gallery mode, and history sync for FC2PPVDB, Supjav, and more. // @license MIT // @icon https://fc2ppvdb.com/favicon.ico // @match https://fc2ppvdb.com/* // @match https://fd2ppv.cc/* // @match https://supjav.com/* // @match https://missav.ws/* // @match https://missav.ai/* // @match https://javdb.com/* // @match https://javdb565.com/* // @require https://unpkg.com/[email protected]/dist/dexie.js // @connect sukebei.nyaa.si // @connect wumaobi.com // @connect fourhoi.com // @connect fd2ppv.cc // @connect fc2ppvdb.com // @connect supabase.co // @connect 0cili.eu // @connect www.javbus.com // @connect www.javlibrary.com // @connect www.dmm.co.jp // @connect adult.contents.fc2.com // @grant GM_addStyle // @grant GM_deleteValue // @grant GM_getValue // @grant GM_info // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_setValue // @grant GM_unregisterMenuCommand // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-end // @noframes // ==/UserScript== (function (Dexie) { 'use strict'; const SCRIPT_INFO = { NAME: "FC2PPVDB Enhanced", VERSION: typeof GM_info !== "undefined" ? GM_info.script.version : "2.0.5", NAMESPACE: "https://greasyfork.org/zh-CN/scripts/552583-fc2ppvdb-enhanced", GREASYFORK_URL: "https://greasyfork.org/scripts/552583" }; const STORAGE_KEYS = { SETTINGS: "settings_v1", CACHE: "magnet_cache_v1", HISTORY: "history_v1", SUPABASE_URL: "supabase_url", SUPABASE_KEY: "supabase_key", SUPABASE_EMAIL: "supabase_email", SUPABASE_PASSWORD: "supabase_password", SUPABASE_JWT: "supabase_jwt", SUPABASE_REFRESH: "supabase_refresh_token", SYNC_USER_ID: "sync_user_id", CURRENT_USER_EMAIL: "current_user_email", LAST_SYNC_TS: "last_sync_ts", LAST_AUTO_SYNC_TS: "last_auto_sync_ts", WEBDAV_URL: "webdav_url", WEBDAV_USER: "webdav_user", WEBDAV_PASS: "webdav_pass", WEBDAV_PATH: "webdav_path", WEBDAV_LAST_ETAG: "webdav_last_etag", WEBDAV_SYNC_LOCK: "webdav_sync_lock", SYNC_MODE: "sync_mode", LANGUAGE: "language", USER_GRID_COLUMNS: "user_grid_columns_preference", FAB_POSITION: "fab_pos_v2", DEBUG_MODE: "fc2_debug_mode" }; const DATABASE = { NAME: "fc2_enhanced_db" }; const CACHE = { MEMORY_MAX_SIZE: 1e3, MEMORY_EXPIRATION_MS: 5 * 60 * 1e3, EXPIRATION_MS: 14 * 24 * 60 * 60 * 1e3, PREVIEW_MAX_SIZE: 5, PREVIEW_PRELOAD_LIMIT: 30 }; const UI_CONSTANTS = { FONT_FAMILY: "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', 'Meiryo', sans-serif", Z_INDEX_MAX: 2147483647, Z_INDEX_OVERLAY: 2147483646, Z_INDEX_TOOLTIP: 999999, DEFAULT_TIMESTAMP: "1970-01-01T00:00:00.000Z", DEFAULT_SYNC_FILENAME: "fc2_enhanced_sync.json", TIME_ZONE: "Asia/Shanghai", PLACEHOLDER_IMAGE: "https://placehold.co/400x300?text=No+Image", SWIPE_DISMISS_THRESHOLD: 100 }; const UI_TOKENS = { COLORS: { SUCCESS: "#34d399", ERROR: "#f87171", WARN: "#fab387", INFO: "#89b4fa", WHITE: "#f4f4f7" }, BACKDROP: { BLUR: "12px", SHADOW: "0 8px 16px rgba(0,0,0,0.4)" }, SPACING: { XS: "4px", MD: "12px" }, RADIUS: { MD: "12px" } }; const DOM_IDS = { UI_HOST: "fc2-modern-ui-host", SETTINGS_HOST: "fc2-enh-settings-host", SETTINGS_CONTAINER: "fc2-enh-settings-container", TAB_CONTENT: "tab-content-container", LOG_LIST: "debug-log-list" }; const CSS_CLASSES = { cardRebuilt: "card-rebuilt", processedCard: "processed-card", hideNoMagnet: "hide-no-magnet", videoPreviewContainer: "video-preview-container", staticPreview: "static-preview", previewElement: "preview-element", hidden: "hidden", infoArea: "info-area", customTitle: "custom-card-title", fc2IdBadge: "fc2-id-badge", badgeCopied: "copied", preservedIconsContainer: "preserved-icons-container", resourceLinksContainer: "resource-links-container", resourceBtn: "resource-btn", btnLoading: "is-loading", btnMagnet: "magnet", tooltip: "tooltip", buttonText: "button-text", extraPreviewContainer: "preview-container", extraPreviewTitle: "preview-title", extraPreviewGrid: "preview-grid", isCensored: "is-censored", hideCensored: "hide-censored", isViewed: "is-viewed", hideViewed: "hide-viewed", isWanted: "is-wanted", isDownloaded: "is-downloaded", isBlocked: "is-blocked", hideBlocked: "hide-blocked" }; const NETWORK = { CHUNK_SIZE: 12 }; const TIMING = { DEBOUNCE_MS: 300, THROTTLE_MS: 200, RETRY_DELAY_MS: 1e3, MAGNET_BASE_DELAY_MS: 800, UI_TRANSITION_FAST: 150, UI_TRANSITION_NORMAL: 300, UI_TRANSITION_SLOW: 500, UI_ANIMATION_BADGE: 600, TOAST_DEFAULT_DURATION: 3e3, RELOAD_DELAY_FAST: 800, RELOAD_DELAY_NORMAL: 1500, SCRIPT_INJECTION_DELAY: 400, SYNC_DEBOUNCE_MS: 2e3, SYNC_INIT_DELAY: 5e3, MAGNET_RANDOM_DELAY_MS: 3e3, CLOUDFLARE_BACKOFF_MS: 6e4, FD2_BACKOFF_MS: 3e5, RATE_LIMIT_BACKOFF_MS: 6e4, OCILI_DELAY_MS: 2e3, OCILI_RANDOM_DELAY_MS: 2e3, ACTRESS_BASE_DELAY_MS: 1500, ACTRESS_RANDOM_DELAY_MS: 1500, POLITE_DELAY_MS: 500, MAGNET_JITTER_MS: 3e3, POLL_INTERVAL_MS: 100, POLL_TIMEOUT_MS: 3e4, DOM_READY_TIMEOUT: 1e4, PREVIEW_ERROR_DELAY: 3e3, LINK_PRELOAD_TIMEOUT: 6e4, MAX_BACKOFF_MS: 6e5 }; const CLOUDFLARE_INDICATORS = ["Just a moment...", "Checking your browser", "Attention Required!", "Cloudflare"]; const VALIDATION = { ACTRESS_NAME_MAX_LENGTH: 20, ACTRESS_NAME_MIN_LENGTH: 2, JAV_ID_REGEX: /^(?=.*[A-Z])[A-Z0-9-]{1,10}-\d{2,8}$/i }; const EXTERNAL_URLS = { SUPJAV: "https://supjav.com/zh/?s={id}", MISSAV_FC2: "https://missav.ws/cn/fc2-ppv-{id}", MISSAV: "https://missav.ws/cn/{id}", JAVDB: "https://javdb.com/search?q={id}&f=all", JAVBUS: "https://www.javbus.com/{id}", JAVLIBRARY: "https://www.javlibrary.com/cn/vl_searchbyid.php?keyword={id}", DMM: "https://www.dmm.co.jp/search/=/searchstr={id}", FC2: "https://adult.contents.fc2.com/article/{id}/?tag=TXpZM05EY3lORFk9", FC2PPVDB: "https://fc2ppvdb.com/articles/{id}", FD2PPV: "https://fd2ppv.cc/articles/{id}", SUKEBEI: "https://sukebei.nyaa.si/?f=0&c=0_0&q={id}", GOOGLE_LENS: "https://lens.google.com/uploadbyurl?url={url}", FOURHOI_COVER: "https://fourhoi.com/fc2-ppv-{id}/cover-t.jpg", FOURHOI_BASE: "https://fourhoi.com", WUMAOBI_COVER: "https://wumaobi.com/fc2daily/data/FC2-PPV-{id}/cover.jpg" }; const SCRAPER_CONFIG = { MAX_CONCURRENT_BATCHES: 4 }; const SCRAPER_URLS = { SUKEBEI_SEARCH: "https://sukebei.nyaa.si/?f=0&c=0_0&q={query}&s=seeders&o=desc", OCILI_SEARCH: "https://0cili.eu/search?q={query}", WUMAOBI_DETAIL: "https://wumaobi.com/fc2daily/detail/FC2-PPV-{id}", WUMAOBI_BASE: "https://wumaobi.com" }; const MAGNET_CONFIG = { MAX_CONCURRENCY: 4, MAX_RETRIES: 2, RETRY_DELAY: 1e3, PREDICTIVE_LIMIT: 12, DEFAULT_TYPE: "fc2", SEARCH_TIMEOUT_MS: 6e4 }; const PREVIEW_BLACKLIST = ["moechat_ads.jpg", "mc.yandex.ru", "linglan_ad1.jpg"]; const JAV_PREFIX_BLACKLIST = ["FC2", "PPV", "PAGE", "LIST", "NEW", "BEST", "FILE", "VIEW"]; const ACTRESS_BLACKLIST_STRINGS = [ "首页", "分类", "我的", "搜索", "排行榜", "导航", "菜单", "更多", "全部", "女优", "无码", "有码", "素人", "流出", "破解", "解密", "合集", "个人拍摄", "個人撮影" ]; const ACTRESS_BLACKLIST = [ /首页/, /分类/, /我的/, /搜索/, /排行榜/, /导航/, /菜单/, /更多/, /全部/, /女优/, /无码/, /有码/, /素人/, /流出/, /破解/, /解密/, /合集/, /个人拍摄/, /個人撮影/, /Top\s*\d+/i, /^[\d\s]+$/ ]; const PATTERNS = { FC2_PPV_PREFIX: "FC2-PPV-", WUMAOBI_COVER: "cover.jpg", WUMAOBI_MAIN: "main.jpg", CENSORED_INDICATOR: "icon-mosaic_free color_free0" }; const SYNC_STATUS = { IDLE: "idle", SYNCING: "syncing", SUCCESS: "success", ERROR: "error", CONFLICT: "conflict" }; const SUPABASE_ENDPOINTS = { TOKEN: "/auth/v1/token", SIGNUP: "/auth/v1/signup", USER_HISTORY: "/rest/v1/user_history" }; const SYSTEM_KEYS = { MESSAGING_CHANNEL: "fc2-enhanced-sync" }; const MAX_HISTORY = 500; const PREFIX = "[FC2-ENH]"; const DEDUP_WINDOW_MS = 2e3; const THROTTLE_WINDOW_MS = 5e3; const THROTTLE_BURST = 3; var LogLevel = ((LogLevel2) => { LogLevel2[LogLevel2["SILENT"] = 0] = "SILENT"; LogLevel2[LogLevel2["ERROR"] = 1] = "ERROR"; LogLevel2[LogLevel2["WARN"] = 2] = "WARN"; LogLevel2[LogLevel2["INFO"] = 3] = "INFO"; LogLevel2[LogLevel2["DEBUG"] = 4] = "DEBUG"; LogLevel2[LogLevel2["TRACE"] = 5] = "TRACE"; LogLevel2[LogLevel2["NONE"] = 0] = "NONE"; LogLevel2[LogLevel2["SUCCESS"] = 3] = "SUCCESS"; return LogLevel2; })(LogLevel || {}); const LEVEL_NAMES = { [ 0 ]: "SILENT", [ 1 ]: "ERROR", [ 2 ]: "WARN", [ 3 ]: "INFO", [ 4 ]: "DEBUG", [ 5 ]: "TRACE" }; const formatTime = () => { const d = new Date(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); const ms = String(d.getMilliseconds()).padStart(3, "0"); return `${hh}:${mm}:${ss}.${ms}`; }; const makeKey = (level, module, message) => `${level}|${module}|${message}`; class LoggerImpl { constructor() { this._enabled = false; this._history = []; this._level = 2; this._timers = new Map(); this._traceCounter = 0; this._groupDepth = 0; this._scopeCache = new Map(); this._dedup = new Map(); this._dedupFlushTimer = null; this._throttle = new Map(); } get enabled() { return this._enabled; } get history() { this._flushDedup(); return [...this._history]; } get traceId() { return `t-${++this._traceCounter}`; } get level() { return this._level; } init() { try { const saved = typeof GM_getValue !== "undefined" ? GM_getValue("fc2_debug_mode", false) : false; if (saved) this.enable(false); } catch { } } enable(persist = true) { this._enabled = true; this._level = 5; if (persist) { try { if (typeof GM_setValue !== "undefined") GM_setValue("fc2_debug_mode", true); } catch { } } } disable(persist = true) { this._enabled = false; this._level = 2; if (persist) { try { if (typeof GM_setValue !== "undefined") GM_setValue("fc2_debug_mode", false); } catch { } } } setLevel(level) { this._level = level; } clear() { this._history.length = 0; this._dedup.clear(); this._throttle.clear(); } scope(module) { let scoped = this._scopeCache.get(module); if (!scoped) { scoped = { error: (msg, data, traceId) => this.error(module, msg, data, traceId), warn: (msg, data, traceId) => this.warn(module, msg, data, traceId), info: (msg, data, traceId) => this.info(module, msg, data, traceId), debug: (msg, data, traceId) => this.debug(module, msg, data, traceId), trace: (msg, data, traceId) => this.trace(module, msg, data, traceId), success: (msg, data, traceId) => this.info(module, msg, data, traceId) }; this._scopeCache.set(module, scoped); } return scoped; } time(label) { this._timers.set(label, performance.now()); } timeEnd(label) { const start = this._timers.get(label); if (start === void 0) return void 0; this._timers.delete(label); const elapsed = (performance.now() - start).toFixed(2); const msg = `${label}: ${elapsed}ms`; this._push(4, "Timer", msg); return msg; } log(module, message, data, traceId) { this._push(3, module, message, data, traceId); } info(module, message, data, traceId) { this._push(3, module, message, data, traceId); } warn(module, message, data, traceId) { this._push(2, module, message, data, traceId); } error(module, message, data, traceId) { this._push(1, module, message, data, traceId); } success(module, message, data, traceId) { this._push(3, module, message, data, traceId); } debug(module, message, data, traceId) { this._push(4, module, message, data, traceId); } trace(module, message, data, traceId) { this._push(5, module, message, data, traceId); } group(module, label, traceId) { this._groupDepth++; this._push(3, module, `[group:start] ${label}`, void 0, traceId); if (this._shouldOutput( 3 )) { console.group(`${PREFIX} [${module}] ${label}`); } } groupEnd() { if (this._groupDepth <= 0) return; this._groupDepth--; if (this._enabled) { console.groupEnd(); } } _shouldOutput(level) { return level <= this._level; } _push(level, module, message, data, traceId) { if (level > this._level && level !== 1) return; const key = makeKey(level, module, message); if (level >= 4) { if (this._checkThrottle(key)) return; } const existing = this._dedup.get(key); if (existing && Date.now() - existing.firstTs < DEDUP_WINDOW_MS) { existing.count++; existing.lastEntry.count = existing.count; if (data !== void 0) existing.lastEntry.data = data; this._scheduleDedupFlush(); return; } this._flushDedup(); const entry = { level, levelName: LEVEL_NAMES[level] ?? "UNKNOWN", module, message, timestamp: formatTime(), count: 1, ...traceId ? { traceId } : {}, ...data !== void 0 ? { data } : {} }; this._dedup.set(key, { count: 1, firstTs: Date.now(), lastEntry: entry, flushed: false }); this._record(entry); this._output(entry); } _scheduleDedupFlush() { if (this._dedupFlushTimer) return; this._dedupFlushTimer = setTimeout(() => { this._dedupFlushTimer = null; this._flushDedup(); }, DEDUP_WINDOW_MS); } _flushDedup() { if (this._dedupFlushTimer) { clearTimeout(this._dedupFlushTimer); this._dedupFlushTimer = null; } for (const [, state] of this._dedup) { if (state.count > 1 && !state.flushed) { state.flushed = true; state.lastEntry.count = state.count; if (this._shouldOutput(state.lastEntry.level)) { const tag = this._formatTag(state.lastEntry); console.debug(`${tag} (x${state.count}) ${state.lastEntry.message}`); } } } const now = Date.now(); for (const [key, state] of this._dedup) { if (now - state.firstTs > DEDUP_WINDOW_MS) { this._dedup.delete(key); } } } _checkThrottle(key) { const now = Date.now(); let state = this._throttle.get(key); if (!state || now - state.windowStart > THROTTLE_WINDOW_MS) { state = { count: 1, windowStart: now, suppressed: 0 }; this._throttle.set(key, state); return false; } state.count++; if (state.count <= THROTTLE_BURST) return false; state.suppressed++; if (state.suppressed === 1) { const capturedState = state; setTimeout( () => { if (capturedState.suppressed > 0) { const entry = { level: 4, levelName: "DEBUG", module: "Throttle", message: `Suppressed ${capturedState.suppressed} repeated messages in ${THROTTLE_WINDOW_MS}ms`, timestamp: formatTime(), count: 1 }; this._record(entry); if (this._shouldOutput( 4 )) { console.debug(this._formatTag(entry), entry.message); } } this._throttle.delete(key); }, THROTTLE_WINDOW_MS - (now - capturedState.windowStart) ); } return true; } _record(entry) { this._history.push(entry); if (this._history.length > MAX_HISTORY) { this._history.splice(0, this._history.length - MAX_HISTORY); } } _formatTag(entry) { const traceTag = entry.traceId ? ` [${entry.traceId}]` : ""; return `${PREFIX}${traceTag} [${entry.levelName}] [${entry.module}]`; } _output(entry) { if (!this._shouldOutput(entry.level)) return; const tag = this._formatTag(entry); const args = [tag, entry.message]; if (entry.data !== void 0) args.push(entry.data); switch (entry.level) { case 1: console.error(...args); break; case 2: console.warn(...args); break; case 4: case 5: console.debug(...args); break; default: console.log(...args); break; } } } const Logger = new LoggerImpl(); const log$D = Logger.scope("Container"); class Container { constructor() { this.services = new Map(); this.initialized = false; } register(name, service) { if (this.services.has(name)) { log$D.warn(`Service ${name} already registered, overwriting`); } this.services.set(name, service); return service; } get(name) { const service = this.services.get(name); if (!service) { throw new Error(`[Container] Service not found: ${name}`); } return service; } async bootstrap() { if (this.initialized) return; Logger.group("Container", "System bootstrap"); try { log$D.debug("Stage 1: Initializing services"); for (const [name, service] of this.services) { if (service.onInit) { try { await service.onInit(); log$D.debug(`Initialized: ${name}`); } catch (error) { log$D.error(`Failed to initialize service: ${name}`, error); } } } log$D.debug("Stage 2: Orchestrating bootstrap"); for (const [name, service] of this.services) { if (service.onBootstrap) { try { await service.onBootstrap(); log$D.debug(`Bootstrapped: ${name}`); } catch (error) { log$D.error(`Failed to bootstrap service: ${name}`, error); } } } this.initialized = true; log$D.info("System bootstrap complete"); } catch (error) { log$D.error("Bootstrap failed", error); throw error; } finally { Logger.groupEnd(); } } async shutdown() { log$D.info("Shutting down services"); for (const service of this.services.values()) { if (service.onCleanup) await service.onCleanup(); } this.services.clear(); this.initialized = false; } } const AppContainer = new Container(); const log$C = Logger.scope("Events"); var AppEvents = ((AppEvents2) => { AppEvents2["BOOTSTRAP"] = "app:bootstrap"; AppEvents2["SERVICES_READY"] = "app:services-ready"; AppEvents2["UI_READY"] = "app:ui-ready"; AppEvents2["STATE_CHANGED"] = "app:state-changed"; AppEvents2["THEME_CHANGED"] = "ui:theme-changed"; AppEvents2["LANGUAGE_CHANGED"] = "ui:language-changed"; AppEvents2["GRID_CHANGED"] = "ui:grid-changed"; AppEvents2["SYNC_STATUS_CHANGED"] = "sync:status-changed"; AppEvents2["HISTORY_LOADED"] = "history:loaded"; AppEvents2["HISTORY_ADDED"] = "history:added"; AppEvents2["HISTORY_REMOVED"] = "history:removed"; AppEvents2["HISTORY_CLEARED"] = "history:cleared"; AppEvents2["SITE_READY"] = "site:ready"; AppEvents2["MAGNET_FOUND"] = "magnet:found"; AppEvents2["MAGNET_FAILED"] = "magnet:failed"; AppEvents2["CARD_READY"] = "card:ready"; AppEvents2["VIEW_STATE_CHANGED"] = "view:state-changed"; AppEvents2["COLLECTION_HEALTH_PROGRESS"] = "collection:health-progress"; AppEvents2["COLLECTION_UPDATED"] = "collection:updated"; AppEvents2["COLLECTION_STATS_CHANGED"] = "collection:stats-changed"; AppEvents2["COLLECTION_EXPORT"] = "collection:export"; AppEvents2["COLLECTION_IMPORT"] = "collection:import"; AppEvents2["SHOW_TOAST"] = "ui:show-toast"; AppEvents2["OPEN_SETTINGS"] = "ui:open-settings"; AppEvents2["PANEL_OPENED"] = "ui:panel-opened"; AppEvents2["PANEL_CLOSED"] = "ui:panel-closed"; AppEvents2["HISTORY_CHANGED"] = "history:changed"; return AppEvents2; })(AppEvents || {}); class CoreEventsImpl { constructor() { this.handlers = new Map(); } on(event, handler) { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); } this.handlers.get(event).add(handler); return () => this.off(event, handler); } off(event, handler) { const handlersSet = this.handlers.get(event); if (handlersSet) { handlersSet.delete(handler); } } emit(event, data) { log$C.trace(`Emit: ${event}`, data); const handlersSet = this.handlers.get(event); if (handlersSet) { handlersSet.forEach((handler) => { try { handler(data); } catch (e) { log$C.error(`Handler error for ${event}`, e); } }); } } } const CoreEvents = new CoreEventsImpl(); const Config = { EXTERNAL_URLS, SCRAPER_URLS, SCRAPER_CONFIG, STORAGE_KEYS, TIMEOUTS: { API: 2e4, VIDEO_LOAD: 5e3, SYNC_DEBOUNCE: 2e3 }, CLASSES: CSS_CLASSES, CACHE_EXPIRATION_DAYS: 14, COPIED_BADGE_DURATION: 1500 }; class CryptoServiceImpl { async getSalt(keyStr) { const encoder = new TextEncoder(); const data = encoder.encode(keyStr); const hash = await crypto.subtle.digest("SHA-256", data); return new Uint8Array(hash).slice(0, 16); } async getKey(password) { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), { name: "PBKDF2" }, false, [ "deriveKey" ]); const salt = await this.getSalt(password); return await crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 1e5, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); } async encrypt(text, password) { if (!password) password = String(GM_info.script.version); try { const key = await this.getKey(password); const iv = crypto.getRandomValues(new Uint8Array(12)); const encoder = new TextEncoder(); const encryptedContent = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(text)); const encryptedBuffer = new Uint8Array(encryptedContent); const combined = new Uint8Array(iv.length + encryptedBuffer.length); combined.set(iv); combined.set(encryptedBuffer, iv.length); let binary = ""; const chunkSize = 8192; for (let i = 0; i < combined.length; i += chunkSize) { binary += String.fromCharCode(...combined.subarray(i, i + chunkSize)); } return btoa(binary); } catch (error) { Logger.error("Crypto", "Encryption failed", error); throw new Error("Encryption failed"); } } async decrypt(encryptedText, password) { if (!password) password = String(GM_info.script.version); try { const combinedStr = atob(encryptedText); const combined = new Uint8Array(combinedStr.length); for (let i = 0; i < combinedStr.length; i++) { combined[i] = combinedStr.charCodeAt(i); } const iv = combined.slice(0, 12); const encryptedBuffer = combined.slice(12); const key = await this.getKey(password); const decryptedContent = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encryptedBuffer); const decoder = new TextDecoder(); return decoder.decode(decryptedContent); } catch (error) { Logger.error("Crypto", "Decryption failed, returning original text", error); return encryptedText; } } async calculateChecksum(data) { const str = typeof data === "string" ? data : JSON.stringify(data); const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(str)); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } } const CryptoService = new CryptoServiceImpl(); const Storage = { get: (key, def) => typeof GM_getValue !== "undefined" ? GM_getValue(key, def) : def, set: (key, val) => { if (typeof GM_setValue !== "undefined") GM_setValue(key, val); }, async getEncrypted(key, def) { const val = this.get(key, ""); if (!val) return def; return await CryptoService.decrypt(val); }, async setEncrypted(key, val) { const encrypted = await CryptoService.encrypt(val); this.set(key, encrypted); }, delete: (key) => { if (typeof GM_deleteValue !== "undefined") GM_deleteValue(key); } }; const Utils = { debounce: (func, delay) => { let t2; return (...a) => { if (t2) clearTimeout(t2); t2 = setTimeout(() => func(...a), delay); }; }, chunk: (arr, size) => Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)), sleep: (ms) => new Promise((res) => setTimeout(res, ms)), copyToClipboard: (text) => { if (typeof GM_setClipboard !== "undefined") GM_setClipboard(text); } }; const MediaUtils = { extractFC2Id: (url) => url?.match(/(?:articles\/|fc2-ppv-|fc2-|^)(\d{5,8})(?:$|\/|\.|_)/i)?.[1] ?? null, parseVideoId: (text, url = "") => { const input = (text + " " + url).replace(/[+\s_]/g, "-").toUpperCase(); const fc2Prefix = input.match(/(?:FC2[^\d]*PPV[^\d]*|FC2-)(\d{5,8})/i); if (fc2Prefix && fc2Prefix[1]) return { id: fc2Prefix[1], type: "fc2" }; const fc2FromUrl = MediaUtils.extractFC2Id(url); if (fc2FromUrl) return { id: fc2FromUrl, type: "fc2" }; const dateMatch = input.match(/(\d{6,8})-(\d{1,4})/); if (dateMatch && dateMatch[1] && dateMatch[2]) { return { id: `${dateMatch[1]}-${dateMatch[2]}`, type: "jav" }; } const jav = input.match(/([A-Z]{2,10})-?(\d{2,8})/); if (jav && jav[1] && jav[2]) { const prefix = jav[1]; const lowerUrl = url.toLowerCase(); const lowerInput = input.toLowerCase(); if (prefix === "DM" && (lowerUrl.includes("/dm") || lowerInput.includes("/dm"))) return null; const blacklist = JAV_PREFIX_BLACKLIST; if (!blacklist.includes(prefix)) { return { id: `${prefix}-${jav[2]}`, type: "jav" }; } } const fc2Raw = input.match(/(?:^|[^A-Z0-9])(\d{5,10})(?:$|[^A-Z0-9])/); if (fc2Raw && fc2Raw[1]) return { id: fc2Raw[1], type: "fc2" }; return null; }, cleanActressName: (name) => { if (!name) return null; const n = name.trim(); const blacklist = ACTRESS_BLACKLIST; if (ACTRESS_BLACKLIST_STRINGS.includes(n) || blacklist.some((reg) => reg.test(n))) return null; if (n.length > VALIDATION.ACTRESS_NAME_MAX_LENGTH || n.length < VALIDATION.ACTRESS_NAME_MIN_LENGTH) return null; return n; }, formatDate: (dateStr) => { if (!dateStr) return ""; try { const date = new Date(dateStr); if (isNaN(date.getTime())) return dateStr; return date.toLocaleDateString(void 0, { year: "numeric", month: "2-digit", day: "2-digit" }); } catch { return dateStr; } }, cleanImageUrl: (url) => { if (!url) return url; return url.replace(/!\d+x\d+\.(jpg|jpeg|png|webp)/gi, ""); } }; const h = (tag, props = {}, ...children) => { const el = document.createElement(tag); for (const [key, val] of Object.entries(props)) { if (key === "className") el.className = val; else if (key === "style" && typeof val === "object" && val !== null) Object.assign(el.style, val); else if (key === "dataset" && typeof val === "object" && val !== null) Object.assign(el.dataset, val); else if (key.startsWith("on") && typeof val === "function") { const eventName = key.toLowerCase().substring(2); el.addEventListener(eventName, val); } else if (key === "innerHTML") { const raw = String(val); if (tag === "style") { el.textContent = raw; } else { const sanitized = raw.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gim, "").replace(/\s*on\w+\s*=\s*("[^"]*"|'[^']*'|[^>\s]+)/gi, "").replace(/javascript:/gi, ""); if (raw !== sanitized) { Logger.warn("DOM", "Sanitized unsafe innerHTML"); } el.innerHTML = sanitized; } } else if (val !== null && val !== void 0 && val !== false) { if (key in el) el[key] = val; else el.setAttribute(key, String(val)); } } children.flat().forEach((child) => { if (child === null || child === void 0 || child === false) return; el.appendChild(child instanceof Node ? child : document.createTextNode(String(child))); }); return el; }; const getBestImageSource = (img) => { if (!img) return ""; const d = img.dataset; const src = d.original || d.src || d.lazySrc || img.src || ""; if (src.startsWith("data:image") || src.includes("pixel.gif")) { return d.original || d.src || d.lazySrc || ""; } return src; }; const IdNormalizer = { normalize(raw) { if (!raw) return ""; const trimmed = raw.trim().toUpperCase(); const fc2Match = trimmed.match(/(?:FC2|PPV)?-?(\d{5,8})/); if (fc2Match) { return fc2Match[1] || ""; } const javMatch = trimmed.match(VALIDATION.JAV_ID_REGEX); if (javMatch) { return trimmed; } return trimmed; }, isSame(id1, id2) { const n1 = this.normalize(id1); const n2 = this.normalize(id2); if (!n1 || !n2) return false; if (n1 === n2) return true; if (n1.replace(/-/g, "") === n2.replace(/-/g, "")) return true; return false; } }; const http = (url, options = {}) => { const respType = options.responseType || options.type || "json"; let data = options.data || options.body; if (data && typeof data === "object") data = JSON.stringify(data); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || "GET", url, headers: { ...data ? { "Content-Type": "application/json" } : {}, ...options.headers || {} }, data, timeout: options.timeout || Config.TIMEOUTS.API, responseType: respType === "json" ? "json" : "text", onload: (res) => { if (res.status >= 200 && res.status < 300) { if (respType === "json") { try { resolve(res.response || JSON.parse(res.responseText)); } catch { resolve(res.responseText); } } else { resolve(res.responseText || res.response); } } else { reject({ status: res.status, statusText: res.statusText, response: res.responseText }); } }, onerror: (err) => reject({ status: 0, statusText: "Network Error", error: err }), ontimeout: () => reject({ status: 408, statusText: "Timeout" }) }); }); }; const IconArrowUp = '<svg viewBox="0 0 384 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.3l105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"/></svg>'; const IconGear = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M495.9 166.6c3.2 8.7.5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4l-55.6 17.8c-8.8 2.8-18.6.3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4c-1.1-8.4-1.7-16.9-1.7-25.5s.6-17.1 1.7-25.4l-43.3-39.4c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160a80 80 0 1 0 0 160"/></svg>'; const IconBan = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5m45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5zM0 256a256 256 0 1 1 512 0a256 256 0 1 1-512 0"/></svg>'; const IconMagnet = '<svg viewBox="0 0 448 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M0 160v96c0 123.7 100.3 224 224 224s224-100.3 224-224v-96H320v96c0 53-43 96-96 96s-96-43-96-96v-96zm0-32h128V64c0-17.7-14.3-32-32-32H32C14.3 32 0 46.3 0 64zm320 0h128V64c0-17.7-14.3-32-32-32h-64c-17.7 0-32 14.3-32 32z"/></svg>'; const IconEyeSlash = '<svg viewBox="0 0 640 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7l-105.2-82.4c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8zm184.3 144.4c25.5-23.3 59.6-37.5 96.9-37.5c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9.5-13.6 1.4-20.2l-94.3-74.3c-22.8 29.7-39.1 59.3-48.6 82.2c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1c47 43.8 111.7 80.6 192.5 80.6c47.8 0 89.9-12.9 126.2-32.5z"/></svg>'; const IconRotate = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M142.9 142.9c-17.5 17.5-30.1 38-37.8 59.8c-5.9 16.7-24.2 25.4-40.8 19.5S38.9 198 44.8 181.4c10.8-30.7 28.4-59.4 52.8-83.8c87.2-87.2 228.3-87.5 315.8-1L455 55c6.9-6.9 17.2-8.9 26.2-5.2S496 62.3 496 72v128c0 13.3-10.7 24-24 24H344c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l41.1-41.1c-62.6-61.5-163.1-61.2-225.3 1zM16 312c0-13.3 10.7-24 24-24h128c9.7 0 18.5 5.8 22.2 14.8s1.7 19.3-5.2 26.2l-41.1 41.1c62.6 61.5 163.1 61.2 225.3-1c17.5-17.5 30.1-38 37.8-59.8c5.9-16.7 24.2-25.4 40.8-19.5s25.4 24.2 19.5 40.8c-10.8 30.6-28.4 59.3-52.9 83.8c-87.2 87.2-228.3 87.5-315.8 1L57 457c-6.9 6.9-17.2 8.9-26.2 5.2S16 449.7 16 440V312.1z"/></svg>'; const IconPlus = '<svg viewBox="0 0 448 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32v144H48c-17.7 0-32 14.3-32 32s14.3 32 32 32h144v144c0 17.7 14.3 32 32 32s32-14.3 32-32V288h144c17.7 0 32-14.3 32-32s-14.3-32-32-32H256z"/></svg>'; const IconImages = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M160 32c-35.3 0-64 28.7-64 64v224c0 35.3 28.7 64 64 64h352c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64zm236 106.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320H200c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6l56-84c4.5-6.6 12-10.6 20-10.6s15.5 4 20 10.7M192 128a32 32 0 1 1 64 0a32 32 0 1 1-64 0m-144-8c0-13.3-10.7-24-24-24S0 106.7 0 120v224c0 75.1 60.9 136 136 136h320c13.3 0 24-10.7 24-24s-10.7-24-24-24H136c-48.6 0-88-39.4-88-88z"/></svg>'; const IconSpinner = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M304 48a48 48 0 1 0-96 0a48 48 0 1 0 96 0m0 416a48 48 0 1 0-96 0a48 48 0 1 0 96 0M48 304a48 48 0 1 0 0-96a48 48 0 1 0 0 96m464-48a48 48 0 1 0-96 0a48 48 0 1 0 96 0M142.9 437A48 48 0 1 0 75 369.1a48 48 0 1 0 67.9 67.9m0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437a48 48 0 1 0 67.9-67.9a48 48 0 1 0-67.9 67.9"/></svg>'; const IconCircleCheck = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512a256 256 0 1 0 0 512m113-303L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>'; const IconCircleXmark = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512a256 256 0 1 0 0 512m-81-337c9.4-9.4 24.6-9.4 33.9 0l47 47l47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47l47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47l-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47l-47-47c-9.4-9.4-9.4-24.6 0-33.9"/></svg>'; const IconTriangleExclamation = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7.2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8.2-40.1l216-368C228.7 39.5 241.8 32 256 32m0 128c-13.3 0-24 10.7-24 24v112c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24m32 224a32 32 0 1 0-64 0a32 32 0 1 0 64 0"/></svg>'; const IconCircleInfo = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512a256 256 0 1 0 0 512m-40-176h24v-64h-24c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24h-80c-13.3 0-24-10.7-24-24s10.7-24 24-24m40-208a32 32 0 1 1 0 64a32 32 0 1 1 0-64"/></svg>'; const IconXmark = '<svg viewBox="0 0 384 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7L86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256L41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3l105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256z"/></svg>'; const IconSliders = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M0 416c0 17.7 14.3 32 32 32h54.7c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H233.3c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48H32c-17.7 0-32 14.3-32 32m128 0a32 32 0 1 1 64 0a32 32 0 1 1-64 0m192-160a32 32 0 1 1 64 0a32 32 0 1 1-64 0m32-80c-32.8 0-61 19.7-73.3 48H32c-17.7 0-32 14.3-32 32s14.3 32 32 32h246.7c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48H480c17.7 0 32-14.3 32-32s-14.3-32-32-32h-54.7c-12.3-28.3-40.5-48-73.3-48m-160-48a32 32 0 1 1 0-64a32 32 0 1 1 0 64m73.3-64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48H32C14.3 64 0 78.3 0 96s14.3 32 32 32h86.7c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48H480c17.7 0 32-14.3 32-32s-14.3-32-32-32z"/></svg>'; const IconAdjustments = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M0 416c0 17.7 14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32L233.3 384c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384c-17.7 0-32 14.3-32 32m128 0a32 32 0 1 1 64 0a32 32 0 1 1-64 0m192-160a32 32 0 1 1 64 0a32 32 0 1 1-64 0m32-80c-32.8 0-61 19.7-73.3 48L32 176c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 240c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48m-160-48a32 32 0 1 1 0-64a32 32 0 1 1 0 64m73.3-64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32z"/></svg>'; const IconDatabase = '<svg viewBox="0 0 448 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M448 80v48c0 44.2-100.3 80-224 80S0 172.2 0 128V80C0 35.8 100.3 0 224 0s224 35.8 224 80m-54.8 134.7c20.8-7.4 39.9-16.9 54.8-28.6V288c0 44.2-100.3 80-224 80S0 332.2 0 288V186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3M0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6V432c0 44.2-100.3 80-224 80S0 476.2 0 432z"/></svg>'; const IconFilter = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M3.9 54.9C10.5 40.9 24.5 32 40 32h432c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9V448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6v-79.1L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9"/></svg>'; const IconPalette = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M512 256v2.7c-.4 36.5-33.6 61.3-70.1 61.3H344c-26.5 0-48 21.5-48 48c0 3.4.4 6.7 1 9.9c2.1 10.2 6.5 20 10.8 29.9c6.1 13.8 12.1 27.5 12.1 42c0 31.8-21.6 60.7-53.4 62c-3.5.1-7 .2-10.6.2C114.6 512 0 397.4 0 256S114.6 0 256 0s256 114.6 256 256m-384 32a32 32 0 1 0-64 0a32 32 0 1 0 64 0m0-96a32 32 0 1 0 0-64a32 32 0 1 0 0 64m160-96a32 32 0 1 0-64 0a32 32 0 1 0 64 0m96 96a32 32 0 1 0 0-64a32 32 0 1 0 0 64"/></svg>'; const IconClockRotateLeft = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M75 75L41 41C25.9 25.9 0 36.6 0 57.9V168c0 13.3 10.7 24 24 24h110.1c21.4 0 32.1-25.9 17-41l-30.8-30.8C155 85.5 203 64 256 64c106 0 192 86 192 192s-86 192-192 192c-40.8 0-78.6-12.7-109.7-34.4c-14.5-10.1-34.4-6.6-44.6 7.9s-6.6 34.4 7.9 44.6C151.2 495 201.7 512 256 512c141.4 0 256-114.6 256-256S397.4 0 256 0C185.3 0 121.3 28.7 75 75m181 53c-13.3 0-24 10.7-24 24v104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65V152c0-13.3-10.7-24-24-24z"/></svg>'; const IconFileExport = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0h160v128c0 17.7 14.3 32 32 32h128v128H216c-13.3 0-24 10.7-24 24s10.7 24 24 24h168v112c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64zm384 272v-48h110.1l-39-39c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l80 80c9.4 9.4 9.4 24.6 0 33.9l-80 80c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l39-39zm0-208H256V0z"/></svg>'; const IconFileImport = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M128 64c0-35.3 28.7-64 64-64h160v128c0 17.7 14.3 32 32 32h128v288c0 35.3-28.7 64-64 64H192c-35.3 0-64-28.7-64-64V336h174.1l-39 39c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l39 39l-174.1.1zm0 224v48H24c-13.3 0-24-10.7-24-24s10.7-24 24-24zm384-160H384V0z"/></svg>'; const IconChevronLeft = '<svg viewBox="0 0 320 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256L246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>'; const IconChevronRight = '<svg viewBox="0 0 320 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256L73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>'; const IconChevronUp = '<svg viewBox="0 0 320 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M182.6 137.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8H288c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"/></svg>'; const IconEye = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32M144 256a144 144 0 1 1 288 0a144 144 0 1 1-288 0m144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3"/></svg>'; const IconLink = '<svg viewBox="0 0 640 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l112.2-112.3c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0z"/></svg>'; const IconBolt = '<svg viewBox="0 0 448 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288h111.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5z"/></svg>'; const IconPlayCircle = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M0 256a256 256 0 1 1 512 0a256 256 0 1 1-512 0m188.3-108.9c-7.6 4.2-12.3 12.3-12.3 20.9v176c0 8.7 4.7 16.7 12.3 20.9s16.8 4.1 24.3-.5l144-88c7.1-4.4 11.5-12.1 11.5-20.5s-4.4-16.1-11.5-20.5l-144-88c-7.4-4.5-16.7-4.7-24.3-.5z"/></svg>'; const IconListUl = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M64 144a48 48 0 1 0 0-96a48 48 0 1 0 0 96m128-80c-17.7 0-32 14.3-32 32s14.3 32 32 32h288c17.7 0 32-14.3 32-32s-14.3-32-32-32zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32h288c17.7 0 32-14.3 32-32s-14.3-32-32-32zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32h288c17.7 0 32-14.3 32-32s-14.3-32-32-32zM64 464a48 48 0 1 0 0-96a48 48 0 1 0 0 96m48-208a48 48 0 1 0-96 0a48 48 0 1 0 96 0"/></svg>'; const IconServer = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96v64c0 35.3 28.7 64 64 64h384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64zm280 72a24 24 0 1 1 0 48a24 24 0 1 1 0-48m48 24a24 24 0 1 1 48 0a24 24 0 1 1-48 0M64 288c-35.3 0-64 28.7-64 64v64c0 35.3 28.7 64 64 64h384c35.3 0 64-28.7 64-64v-64c0-35.3-28.7-64-64-64zm280 72a24 24 0 1 1 0 48a24 24 0 1 1 0-48m56 24a24 24 0 1 1 48 0a24 24 0 1 1-48 0"/></svg>'; const IconMagnifyingGlass = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M416 208c0 45.9-14.9 88.3-40 122.7l126.6 126.7c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208M208 352a144 144 0 1 0 0-288a144 144 0 1 0 0 288"/></svg>'; const IconStar = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M316.9 18L256 126.3L195.1 18c-11.7-20.7-41.5-20.7-53.2 0l-57.8 102.3l-114.7 16c-23.7 3.3-33.2 32.4-16 49.3L42 278.4l-22.1 128.8c-4 23.3 20.6 41.2 41.6 30.2L160 374l98.5 63.3c20.9 11 45.6-7 41.6-30.2L278 278.4l88.7-92.8c17.2-16.9 7.7-46-16-49.3l-114.7-16L178.2 18c-11.7-20.7-41.5-20.7-53.2 0z"/></svg>'; const IconHouse = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M280.4 7.3c-1.3-1.4-3.1-2.2-5-2.2s-3.7.8-5 2.2L34.1 231.2C22.6 242.7 16 258.1 16 274v142.1c0 35.3 28.7 64 64 64h112v-128c0-17.7 14.3-32 32-32h64c17.7 0 32 14.3 32 32v128h112c35.3 0 64-28.7 64-64V274c0-15.9-6.6-31.3-18.1-42.8L305.4 7.3z"/></svg>'; const IconPlay = '<svg viewBox="0 0 384 512" width="1.2em" height="1.2em" fill="currentColor"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>'; const IconPause = '<svg viewBox="0 0 320 512" width="1.2em" height="1.2em" fill="currentColor"><path d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg>'; const IconImageSearch = '<svg viewBox="0 0 512 512" width="1.2em" height="1.2em" fill="currentColor"><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208zM184 96c-13.3 0-24 10.7-24 24v91L114.7 175c-7.2-7.2-18.7-7.2-25.9 0s-7.2 18.7 0 25.9L151.1 263c7.2 7.2 18.7 7.2 25.9 0L240 201c7.2-7.2 7.2-18.7 0-25.9s-18.7-7.2-25.9 0L152 237.1V120c0-13.3-10.7-24-24-24z"/></svg>'; const IconStarRegular = '<svg viewBox="0 0 576 512" width="1.2em" height="1.2em" fill="currentColor"><path fill="currentColor" d="M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L433.6 328.4l26.2 155.6c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7l-137-73.2L151 509.1c-8.1 4.3-17.9 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6 68.6-141.3C270.3 5.2 278.7 0 287.9 0zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 217.9 184.9 303c5.5 5.5 8.1 13.3 6.8 21.1l-20.3 120.3L290 380c7.1-3.8 15.6-3.8 22.8 0l118.6 64.4-20.3-120.3c-1.3-7.8 1.3-15.6 6.8-21.1l85.9-85.1-118.3-17.4c-7.9-1.2-14.6-6.2-18.1-13.3L287.9 79z"/></svg>'; class FC2Database extends Dexie { constructor() { super(DATABASE.NAME); this.version(6).stores({ history: "&id, timestamp, status, updated_at, is_deleted, sync_dirty, retry_count, [is_deleted+timestamp], [sync_dirty+updated_at], [status+is_deleted], [retry_count+sync_dirty]", cache: "&id, timestamp", itemDetails: "&id, lastAccessed, folder" }); this.version(7).stores({ itemDetails: "&id, lastAccessed, folder" }).upgrade(() => { }); } } class QueryCache { constructor() { this.cache = new Map(); this.version = 0; } get(key) { const entry = this.cache.get(key); return entry && entry.version === this.version ? entry.data : null; } set(key, data) { this.cache.set(key, { data, version: this.version }); } invalidate() { this.cache.clear(); this.version = this.version >= Number.MAX_SAFE_INTEGER ? 0 : this.version + 1; } } class MemoryCache { constructor() { this.cache = new Map(); } get(id, expirationMs) { const item = this.cache.get(id); if (item && Date.now() - item.timestamp < expirationMs) return item.value; if (item) this.cache.delete(id); return null; } set(id, value) { if (this.cache.size >= CACHE.MEMORY_MAX_SIZE) { const firstKey = this.cache.keys().next().value; if (firstKey) this.cache.delete(firstKey); } this.cache.set(id, { value, timestamp: Date.now() }); } clear() { this.cache.clear(); } delete(id) { this.cache.delete(id); } } const log$B = Logger.scope("Database"); class DataService { constructor() { this.qCache = new QueryCache(); this.mCache = new MemoryCache(); this.db = new FC2Database(); } async onInit() { log$B.debug("Opening Dexie database"); await this.db.open(); log$B.info("Database opened"); } get history() { const db2 = this.db; const qCache = this.qCache; return { table: db2.history, async add(id, status = "watched") { const now = ( new Date()).toISOString(); const item = { id: String(id), timestamp: Date.now(), status, updated_at: now, is_deleted: 0, sync_dirty: 1 }; await db2.history.put(item); qCache.invalidate(); log$B.trace(`History added: ${id} as ${status}`); }, async getAll() { const cached = qCache.get("all"); if (cached) return cached; const items = await db2.history.where("is_deleted").equals(0).toArray(); qCache.set("all", items); return items; }, async remove(id) { const now = ( new Date()).toISOString(); await db2.history.update(String(id), { is_deleted: 1, updated_at: now, sync_dirty: 1 }); qCache.invalidate(); }, async clear() { await db2.history.clear(); qCache.invalidate(); }, async markDirty(id) { const now = ( new Date()).toISOString(); await db2.history.update(String(id), { sync_dirty: 1, updated_at: now }); qCache.invalidate(); } }; } get cache() { const db2 = this.db; const mCache = this.mCache; return { async get(id) { const exp = CACHE.MEMORY_EXPIRATION_MS; const memValue = mCache.get(id, exp); if (memValue !== null) return memValue; const row = await db2.cache.get(id); if (row && Date.now() - row.timestamp < exp) { mCache.set(id, row.value); return row.value; } return null; }, async set(id, value) { await db2.cache.put({ id: String(id), value, timestamp: Date.now() }); mCache.set(id, value); }, async delete(id) { await db2.cache.delete(String(id)); mCache.delete(id); }, async clear() { await db2.cache.clear(); mCache.clear(); }, async getAll() { return await db2.cache.toArray(); } }; } async runGC() { Logger.group("Database", "Garbage collection"); const now = Date.now(), cacheExp = CACHE.EXPIRATION_MS; await this.db.cache.where("timestamp").below(now - cacheExp).delete(); this.mCache.clear(); Logger.groupEnd(); } get details() { const db2 = this.db; return { async set(id, data) { const nid = String(id); const existing = await db2.itemDetails.get(nid); await db2.itemDetails.put({ id: nid, lastAccessed: Date.now(), updated_at: ( new Date()).toISOString(), ...existing, ...data }); }, async get(id) { const item = await db2.itemDetails.get(String(id)); if (item) { await db2.itemDetails.update(String(id), { lastAccessed: Date.now() }); } return item; }, async getAll() { return await db2.itemDetails.toArray(); }, async remove(id) { await db2.itemDetails.delete(String(id)); }, async clear() { await db2.itemDetails.clear(); }, async updateFolder(id, folder) { await db2.itemDetails.update(String(id), { folder, updated_at: ( new Date()).toISOString() }); }, async batchUpdate(ids, data) { const now = ( new Date()).toISOString(); await db2.transaction("rw", db2.itemDetails, async () => { for (const id of ids) { await db2.itemDetails.update(String(id), { ...data, updated_at: now }); } }); }, async batchDelete(ids) { await db2.itemDetails.bulkDelete(ids.map((id) => String(id))); } }; } } const Repository = AppContainer.register("data-service", new DataService()); Repository.cache; Repository.db; const log$A = Logger.scope("Messaging"); var MessageType = ((MessageType2) => { MessageType2["SETTING_UPDATE"] = "SETTING_UPDATE"; MessageType2["HISTORY_UPDATE"] = "HISTORY_UPDATE"; MessageType2["UI_REFRESH"] = "UI_REFRESH"; return MessageType2; })(MessageType || {}); class MessagingServiceImplementation { constructor() { this.CHANNEL_NAME = SYSTEM_KEYS.MESSAGING_CHANNEL; this.tabId = Math.random().toString(36).substring(2, 11); this.channel = new BroadcastChannel(this.CHANNEL_NAME); this.listeners = new Set(); } onInit() { this.channel.onmessage = (event) => { const msg = event.data; if (msg.sourceTabId === this.tabId) return; log$A.debug(`Received ${msg.type} from other tab`); this.listeners.forEach((l) => l(msg)); }; } broadcast(type, payload) { const msg = { type, payload, sourceTabId: this.tabId }; this.channel.postMessage(msg); log$A.debug(`Broadcasted ${type}`); } onMessage(handler) { this.listeners.add(handler); return () => this.listeners.delete(handler); } destroy() { this.listeners.clear(); this.channel.close(); } } const MessagingService = AppContainer.register("messaging-service", new MessagingServiceImplementation()); const log$z = Logger.scope("History"); class HistoryServiceImplementation { constructor() { this.CACHE_SIZE = 5e3; this.historyCache = new Map(); } getHistorySnapshot() { return this.historyCache; } onBootstrap() { log$z.debug("Warming up cache"); this.load().catch((e) => log$z.error("Failed to load history cache", e)); MessagingService.onMessage((msg) => { if (msg.type === MessageType.HISTORY_UPDATE) { const { action, id, status } = msg.payload; if (action === "add" && status) this.add(id, status, true); else if (action === "remove") this.remove(id, true); else if (action === "clear") this.clear(true); } }); } touch(key, value) { if (this.historyCache.has(key)) this.historyCache.delete(key); this.historyCache.set(key, value); if (this.historyCache.size > this.CACHE_SIZE) { const firstKey = this.historyCache.keys().next().value; if (firstKey) this.historyCache.delete(firstKey); } } getNormalizedId(id) { return IdNormalizer.normalize(id); } getCache() { return new Map(this.historyCache); } async load() { Logger.time("HistoryService.load"); if (!State.proxy.enableHistory) { this.historyCache.clear(); return; } try { let items = []; try { items = await Repository.history.table.orderBy("timestamp").reverse().limit(this.CACHE_SIZE).toArray(); } catch (idxErr) { log$z.warn("Index scan failed, falling back to full scan", idxErr); items = await Repository.history.table.toArray(); items.sort((a, b) => b.timestamp - a.timestamp); if (items.length > this.CACHE_SIZE) items = items.slice(0, this.CACHE_SIZE); } this.historyCache.clear(); for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; if (item) this.historyCache.set(this.getNormalizedId(item.id), item.status || "watched"); } CoreEvents.emit(AppEvents.HISTORY_LOADED, items.length); log$z.info(`Loaded ${items.length} recent items`); } catch (e) { log$z.error("Failed to load history", e); this.historyCache.clear(); } Logger.timeEnd("HistoryService.load"); } async add(id, status = "watched", remote = false) { if (!State.proxy.enableHistory || !id) return; const idStr = this.getNormalizedId(String(id)); if (this.historyCache.get(idStr) === status) { this.touch(idStr, status); if (!remote) return; } this.touch(idStr, status); if (!remote) { await Repository.history.add(idStr, status); MessagingService.broadcast(MessageType.HISTORY_UPDATE, { action: "add", id: idStr, status }); } CoreEvents.emit(AppEvents.HISTORY_ADDED, { id: idStr, status }); log$z.debug(`${remote ? "Remote" : "Local"} added: ${idStr} as ${status}`); if (!remote) CoreEvents.emit(AppEvents.HISTORY_CHANGED, {}); } async remove(id, remote = false) { if (!State.proxy.enableHistory || !id) return; const idStr = this.getNormalizedId(String(id)); const prevStatus = this.historyCache.get(idStr); this.historyCache.delete(idStr); if (!remote) { await Repository.history.remove(idStr); MessagingService.broadcast(MessageType.HISTORY_UPDATE, { action: "remove", id: idStr }); } CoreEvents.emit(AppEvents.HISTORY_REMOVED, { id: idStr, prevStatus }); log$z.debug(`${remote ? "Remote" : "Local"} removed: ${idStr}`); if (!remote) CoreEvents.emit(AppEvents.HISTORY_CHANGED, {}); } async clear(remote = false) { const count = this.historyCache.size; this.historyCache.clear(); if (!remote) { await Repository.history.clear(); MessagingService.broadcast(MessageType.HISTORY_UPDATE, { action: "clear" }); } CoreEvents.emit(AppEvents.HISTORY_CLEARED, count); log$z.warn(`${remote ? "Remote" : "Local"} cleared ${count} items`); if (!remote) CoreEvents.emit(AppEvents.HISTORY_CHANGED, {}); } has(id, status) { if (!State.proxy.enableHistory) return false; const idStr = this.getNormalizedId(String(id)); if (status) return this.historyCache.get(idStr) === status; return this.historyCache.has(idStr) && this.historyCache.get(idStr) !== "blocked"; } getStatus(id) { const idStr = this.getNormalizedId(String(id)); return this.historyCache.get(idStr) || null; } } const HistoryService = AppContainer.register("history-service", new HistoryServiceImplementation()); const translations = { zh: { managementCenter: "管理中心", settingsTitle: SCRIPT_INFO.NAME, tabSettings: "基本设置", tabStatistics: "数据概览", tabData: "同步与导出", tabCollection: "我的收藏", tabDashboard: "运行状态", tabAbout: "关于", tabDebug: "运行日志", tabDmca: "免责声明", dashCollStatus: "收藏统计", dashCollCountLabel: "已收藏条目", dashEnterColl: "打开收藏库", dashSyncTitle: "同步连接", dashSyncStatusDetecting: "检测中...", dashSyncHeartbeatWait: "等待响应...", dashSyncConsole: "同步日志", dashHealthTitle: "数据维护", dashCacheSizeLabel: "缓存占用", dashRunRepair: "自动修复损坏封面", dashViewReport: "查看结果", dashRepairConfirm: "确认开始修复失效封面?系统将尝试从备用源重新抓取图片。", dashSyncModeNone: "同步未开启", dashSyncModeWebDAV: "WebDAV 已连接", dashSyncModeSupabase: "Supabase 已连接", dashSyncSuggestWebDAV: "推荐开启 WebDAV 同步以防止数据丢失", groupFilters: "内容过滤", optionHideNoMagnet: "隐藏无磁力资源", optionHideCensored: "过滤有码内容", optionHideViewed: "过滤已看内容", optionHideBlocked: "隐藏黑名单内容", groupAppearance: "界面与交互", groupExternalPortals: "外部传送门", labelPreviewMode: "预览功能", previewModeStatic: "静态封面", previewModeHover: "悬停/点击播放预览", labelGridColumns: "网格排版", labelGridAuto: "自适应宽度", labelDefault: "默认", labelLanguage: "显示语言", langAuto: "跟随浏览器", langZh: "简体中文", langEn: "English", groupDataHistory: "功能清单", optionEnableHistory: "开启足迹追踪", optionLoadExtraPreviews: "自动加载详情页视频截图", optionEnableQuickBar: "显示右下角悬浮球", optionShowViewedBtn: "显示卡片已阅开关", optionShowIdBadge: "显示番号复制按钮", optionEnableMagnets: "开启磁力资源聚合", optionEnableExternalLinks: "开启外部站点跳转", optionEnableActressName: "显示演职人员姓名", optionReplaceFc2Covers: "FC2PPVDB自动补全高清封面", labelCacheManagement: "缓存管理", btnClearCache: "清理磁力缓存", labelHistoryManagement: "历史管理", btnClearHistory: "清空所有历史记录", btnSaveAndApply: "保存并刷新", alertSettingsSaved: "设置已保存", alertCacheCleared: "磁力缓存已清理", alertHistoryCleared: "所有历史记录已抹除", alertMarkedWanted: "已加入收藏", alertMarkedBlocked: "已加入黑名单", menuOpenSettings: "⚙️ 脚本配置", tooltipCopyMagnet: "复制磁力链接", tooltipCopied: "已复制", tooltipLoading: "正在搜索...", extraPreviewTitle: "画廊预览", alertNoPreview: "未找到预览图", alertNoExternalLinks: "未找到跳转链接", labelExternalLinks: "外部传送门", groupDataManagement: "备份与恢复", btnExportData: "导出所有数据", btnImportData: "从备份恢复", btnResetDatabase: "重置所有设置", alertExportSuccess: "备份文件已开始下载", alertImportSuccess: "数据恢复成功,即将重新载入", alertImportError: "恢复失败:无效的备份文件", tooltipMarkAsViewed: "标为已阅", tooltipMarkAsUnviewed: "标为未阅", tooltipMarkAsWanted: "加入收藏", tooltipMarkAsUnwanted: "移出收藏", tooltipMarkAsBlocked: "屏蔽此条目", tooltipMarkAsUnblocked: "解除屏蔽", confirmResetDatabase: "警告:此操作将清空所有本地数据和配置。确定重置?", alertDatabaseReset: "重置完成", groupWebDAV: "WebDAV 同步", labelWebDAVUrl: "服务器地址", labelWebDAVUser: "账户", labelWebDAVPass: "密码/Token", labelWebDAVPath: "备份文件名", btnWebDAVTest: "测试连接", btnWebDAVSync: "立即同步", btnForceSync: "强制重新同步", alertWebDAVSuccess: "连接成功", alertWebDAVError: "连接失败,请检查配置", alertWebDAVSyncSuccess: "云同步已完成", alertWebDAVSyncError: "同步失败:", syncStatus: "连接状态", labelSyncMode: "同步模式", syncModeNone: "关闭", syncModeSupabase: "Supabase 云端 (高频)", syncModeWebDAV: "WebDAV 协议 (推荐)", labelLastSync: "上次同步时间", labelNever: "从未同步", labelSyncing: "同步中...", alertAlreadyUpToDate: "数据已同步到最新", alertSyncConflict: "发现云端数据有更新", alertSyncLocked: "同步任务已在其他标签页运行中", alertSyncLockActive: "其他标签页正在更新,请稍候", labelSyncInterval: "自动同步频率", syncInterval0: "实时 (较高占用)", syncInterval2: "每 2 分钟", syncInterval5: "每 5 分钟 (推荐)", syncInterval10: "每 10 分钟", syncInterval30: "每 30 分钟", syncIntervalManual: "仅手动触发", labelConflictTitle: "数据版本冲突", labelConflictDesc: "云端文件更新,请选择处理方式:", btnMergeSync: "双向合并", btnForceLocal: "覆盖本地数据", confirmOverwriteLocal: "警告:由于版本冲突,此操作将导致本地改动丢失。确定执行?", labelAdvancedConfig: "开发者设置", labelAuthEmail: "邮箱", labelAuthPass: "密码", btnConnectAndSync: "登录并同步", btnForcePull: "强制云端恢复", btnPullSync: "仅强制拉取云端数据", btnLogout: "注销", alertLoginRequired: "请填写登录信息", alertSbUrlRequired: "缺少 Supabase 配置信息", alertSyncAccountConnected: "账号连接成功", alertPushAllQuery: "确定将本地数据全量推送到云端?", alertPullAllQuery: "确定从云端覆盖本地数据?这将丢弃所有本地未同步的改动。", statusOn: "开启", statusOff: "关闭", labelSupabaseSync: "Supabase 同步", labelSupabaseUrl: "服务器地址", labelSupabaseKey: "密钥 (anon key)", labelUser: "当前用户", labelNotLoggedIn: "未登录", labelConfigError: "配置错误", aboutDescription: "为 FC2PPVDB 等站点提供磁力评分聚合、高清封面替换、画廊模式及历史记录同步。", aboutHelpTitle: "主要功能", aboutHelpContent: "实时显示 Sukebei / 0cili 搜索结果,并自动过滤失效条目。\n自动补全高清封面,详情页支持全屏画廊及键盘操作。\n支持 WebDAV 或 Supabase 协议,安全存储多设备浏览历史。\n右下角悬浮球、快速复制番号、外部站点一键直达。", aboutLinks: "相关链接", aboutVersion: "当前版本", navExtraPreviews: "查看详情", labelTechnicalLogs: "系统日志", btnCopy: "复制内容", btnCopyAll: "复制全部", btnSelectAll: "全选", btnDeselectAll: "取消全选", btnClearLogs: "清空日志", alertLogsCopied: "日志已复制到剪贴板", labelLogFilters: "日志过滤", tooltipClickToCopy: "复制条目", btnCancel: "取消", btnSave: "确认保存", btnMoreOptions: "更多功能", btnClose: "退出", btnBackToTop: "回顶部", labelDebugMode: "调试模式", statusDebugOn: "开发模式", statusDebugOff: "常规模式", alertDebugOn: "调试模式已激活", alertDebugOff: "已回到常规模式", btnCopyEnv: "导出诊断信息", alertEnvCopied: "诊断信息已复制", groupExternalImport: "数据导入/导出", alertMarkedViewed: "已标记为看过", tooltipCopyId: "复制番号", verifyCF: "手动验证 Cloudflare", dmcaContent: "本脚本仅为本地增强型工具,<b>不托管或存储任何视频或图片文件</b>。所有索引内容均来自第三方公开网站。使用者需对使用行为涉及的法律风险自行负责。如有侵权内容展示,请联系该内容的来源网站发起移除。", confirmReloadSettings: "设置已更改,需刷新页面生效。是否立即刷新?", errorWebDAVUrl: "服务器地址必须以 http:// 或 https:// 开头", btnPushPull: "手动同步 (上传/下载)", btnLogData: "查看详情", labelLogData: "数据日志", labelDisclaimer: "声明条款", labelGreasyFork: "Greasy Fork 主页", labelHidden: "隐藏", labelVisible: "显示", labelLoading: "加载中...", alertLoggedOut: "已成功退出", alertUserIdMissing: "同步失败:无法识别用户身份,请尝试重新登录", mainPreview: "功能演示", gallerySearch: "以图搜图", gallerySlideshow: "开启幻灯片播放", searchPlaceholder: "在当前列表中搜索...", collBatchMode: "批量编辑", collSelected: "已选 {count} 项", collSortNewest: "按时间 (从新到旧)", collSortOldest: "按时间 (从旧到新)", collSortTitle: "按标题 (A-Z)", collSortSite: "按来源网站", collFolderAll: "全部目录", collSiteAll: "所有站点", collCheckHealth: "扫描失效链接", collShowDupes: "查找重复项", collBatchMerge: "数据合并", collBatchMove: "移动至文件夹...", collBatchRemove: "批量移除", collBatchRemoveSuccess: "所选项目已移除", collImportJson: "外部备份导入", collExportJson: "备份数据到本地", collUndo: "撤销操作", collMerging: "合并处理中...", collMoveTarget: "选择目标目录 (现有: {folders})", collMoveSuccess: "已成功移动 {count} 个条目", collRemoveSuccess: "已成功移除", collTotal: "总记录数", collShown: "当前可见", labelError: "发生错误", confirmDelete: "确定物理删除?此操作不可撤销。", confirmDeleteSelected: "确定彻底清理选中的 {count} 个条目?", confirmForceSync: "确定执行强制覆盖同步?系统将不对比差异。", confirmDestructiveAction: "注意:此操作具有破坏性,执行后数据无法找回。", folderWanted: "收藏夹", folderViewed: "已阅历史", folderFollow: "订阅列表", labelAll: "全部条目", titleEditMetadata: "校对元数据", labelRating: "打分", labelNotes: "备注信息", labelUserTags: "标签", placeholderAddTag: "输入标签并按回车...", alertMetadataSaved: "设置保存成功", collNewFolder: "新建文件夹...", collRenameFolder: "重命名", collDeleteFolder: "删除当前文件夹", promptFolderName: "请输入文件夹名称", promptNewFolderName: "请输入新的名称", confirmDeleteFolder: '确定删除文件夹 "{folder}"?条目将自动回落到全部列表。', alertFolderRenamed: "重命名已完成", collStatsRated: "已评分", collStatsAvg: "平均", collStatsWithNotes: "有备注", collStatsWithTags: "有标签", collSortFolder: "按文件夹", collHealthResult: "检查完成:扫描 {checked} 项,修复 {repaired} 项", collImportSuccess: "成功导入 {count} 个条目", collImportPartial: "已导入 {count} 个条目,{errors} 个失败" }, en: { managementCenter: "Management Center", settingsTitle: SCRIPT_INFO.NAME, tabSettings: "Preferences", tabStatistics: "Analytics", tabData: "Data & Sync", tabCollection: "Collection", tabDashboard: "Overview", tabAbout: "About", tabDebug: "Technical Logs", tabDmca: "Disclaimer", dashCollStatus: "Collection Status", dashCollCountLabel: "Items Collected", dashEnterColl: "Enter Collection", dashSyncTitle: "System Connectivity", dashSyncStatusDetecting: "Detecting...", dashSyncHeartbeatWait: "Waiting for heartbeat...", dashSyncConsole: "Sync Console", dashHealthTitle: "Lifecycle & Self-Healing", dashCacheSizeLabel: "Local Cache Size (items)", dashRunRepair: "Run Repair", dashViewReport: "View Report", dashRepairConfirm: "Run global self-healing? System will scan failed covers and try fallback sources.", dashSyncModeNone: "Sync Disabled", dashSyncModeWebDAV: "WebDAV Exported", dashSyncModeSupabase: "Supabase Connected", dashSyncSuggestWebDAV: "Recommended: Enable WebDAV in Sync settings", groupFilters: "Filters", optionHideNoMagnet: "Filter No-Magnet", optionHideCensored: "Filter Censored", optionHideViewed: "Filter Viewed", optionHideBlocked: "Filter Blocked", groupAppearance: "Appearance", groupExternalPortals: "External Portals (Custom)", labelPreviewMode: "Preview Mode", previewModeStatic: "Static Cover", previewModeHover: "Hover/Click Play", labelGridColumns: "Grid Columns", labelGridAuto: "Auto Fit (Recommended)", labelDefault: "Default", labelLanguage: "Language", langAuto: "Auto", langZh: "Chinese", langEn: "English", groupDataHistory: "Enhancements", optionEnableHistory: "Track History", optionLoadExtraPreviews: "Preload Gallery", optionEnableQuickBar: "Show Quick FAB", optionShowViewedBtn: "Show Card Viewed Button", optionShowIdBadge: "Show Card ID Badge", optionEnableMagnets: "Enable Magnet Search", optionEnableExternalLinks: "Show External Links", optionEnableActressName: "Show Actress Name", optionReplaceFc2Covers: "Replace FC2PPVDB Covers", labelCacheManagement: "Storage", btnClearCache: "Clear Magnet Cache", labelHistoryManagement: "History", btnClearHistory: "Clear History", btnSaveAndApply: "Save Changes", alertSettingsSaved: "Settings saved", alertCacheCleared: "Cache cleared", alertHistoryCleared: "History cleared", alertMarkedWanted: "Added to Wanted", alertMarkedBlocked: "Blocked Works", menuOpenSettings: "⚙️ Settings", tooltipCopyMagnet: "Magnet", tooltipCopied: "Copied", tooltipLoading: "Searching...", extraPreviewTitle: "Gallery", alertNoPreview: "No Previews", alertNoExternalLinks: "No external links found", labelExternalLinks: "Links", groupDataManagement: "Backup", btnExportData: "Export Backup", btnImportData: "Restore Backup", btnResetDatabase: "Reset All", alertExportSuccess: "Backup started", alertImportSuccess: "Restore successful, refreshing...", alertImportError: "Restore failed: Invalid file", tooltipMarkAsViewed: "Mark viewed", tooltipMarkAsUnviewed: "Unmark viewed", tooltipMarkAsWanted: "Add to Wanted", tooltipMarkAsUnwanted: "Remove from Wanted", tooltipMarkAsBlocked: "Block this", tooltipMarkAsUnblocked: "Unblock this", confirmResetDatabase: "Danger: Delete ALL data and settings?", alertDatabaseReset: "Reset complete", groupWebDAV: "WebDAV Sync", labelWebDAVUrl: "Server URL", labelWebDAVUser: "Username", labelWebDAVPass: "Password/Token", labelWebDAVPath: "Filename", btnWebDAVTest: "Test", btnWebDAVSync: "Sync Now", btnForceSync: "Force Full Sync", alertWebDAVSuccess: "Connected", alertWebDAVError: "Connection failed", alertWebDAVSyncSuccess: "Sync Complete", alertWebDAVSyncError: "Sync Failed: ", syncStatus: "Status", labelSyncMode: "Strategy", syncModeNone: "Off", syncModeSupabase: "Supabase (Adv)", syncModeWebDAV: "WebDAV (Rec)", labelLastSync: "Last Sync", labelNever: "Never", labelSyncing: "Syncing...", alertAlreadyUpToDate: "Already up to date", alertSyncConflict: "Remote Update Detected", alertSyncLocked: "Sync already in progress in another tab", alertSyncLockActive: "Sync in progress in another tab...", labelSyncInterval: "Auto-Sync Interval", syncInterval0: "Real-time (No limit)", syncInterval2: "2 minutes", syncInterval5: "5 minutes (Recommended)", syncInterval10: "10 minutes", syncInterval30: "30 minutes", syncIntervalManual: "Manual only", labelConflictTitle: "Conflict", labelConflictDesc: "Remote data is newer. Strategy:", btnMergeSync: "Merge", btnForceLocal: "Overwrite", confirmOverwriteLocal: "Overwrite local history from remote?", labelAdvancedConfig: "Dev Config", labelAuthEmail: "Email", labelAuthPass: "Password", btnConnectAndSync: "Login", btnForcePull: "Force Pull (Restore)", btnPullSync: "Force Pull Sync", btnLogout: "Logout", alertLoginRequired: "Credentials missing", alertSbUrlRequired: "URL missing", alertSyncAccountConnected: "Connected", alertPushAllQuery: "Push all local data?", alertPullAllQuery: "Restore all data from cloud? This will overwrite local conflicts.", statusOn: "ON", statusOff: "OFF", labelSupabaseSync: "Supabase", labelSupabaseUrl: "Supabase URL", labelSupabaseKey: "Supabase Key", labelUser: "User", labelNotLoggedIn: "Not Configured", labelConfigError: "Config Error", aboutDescription: "Inject magnet links, HD covers, gallery mode, and history sync for FC2PPVDB, Supjav, and more.", aboutHelpTitle: "Features", aboutHelpContent: "• <b>Magnet Links</b>: Aggregated Sukebei / 0cili search with smart filtering.\n• <b>Visuals</b>: Auto-replace HD covers, gallery mode with keyboard nav.\n• <b>Cloud Sync</b>: Sync history via WebDAV / Supabase across devices.\n• <b>UX</b>: FAB, instant ID copy, external link shortcuts.", aboutLinks: "Links", aboutVersion: "Version", navExtraPreviews: "Gallery", labelTechnicalLogs: "Technical Logs", btnCopy: "Copy", btnCopyAll: "Copy All", btnSelectAll: "Select All", btnDeselectAll: "Deselect All", btnClearLogs: "Clear", alertLogsCopied: "Logs copied to clipboard", labelLogFilters: "Filters", tooltipClickToCopy: "Copy", btnCancel: "Cancel", btnSave: "Save", btnMoreOptions: "More Options", btnClose: "Close", btnBackToTop: "Top", labelDebugMode: "Debug", statusDebugOn: "Debug ON", statusDebugOff: "Normal", alertDebugOn: "Debug Enabled", alertDebugOff: "Debug Disabled", btnCopyEnv: "Copy Diagnostics", alertEnvCopied: "Diagnostic info copied", groupExternalImport: "External Import", alertMarkedViewed: "Marked", tooltipCopyId: "Copy ID", verifyCF: "Verify CF", dmcaContent: "This script is a utility tool and <b>does NOT host any video or image files</b>. All content (including images, magnet links) is provided by third-party public public websites. Users assume full responsibility for using this tool. If content displayed by this tool infringes your rights, please contact the source website directly for removal.", confirmReloadSettings: "Some settings require a reload to take effect. Reload now?", errorWebDAVUrl: "URL must start with http:// or https://", btnPushPull: "Push/Pull", btnLogData: "Data", labelLogData: "Log Data", labelDisclaimer: "Disclaimer", labelGreasyFork: "Greasy Fork", labelHidden: "Hidden", labelVisible: "Visible", labelLoading: "Loading...", alertLoggedOut: "Logged out", alertUserIdMissing: "Sync failed: User ID missing. Please login again.", mainPreview: "Feature Preview", gallerySearch: "Search Image", gallerySlideshow: "Slideshow", searchPlaceholder: "Search in collection...", collBatchMode: "Batch Mode", collSelected: "{count} Selected", collSortNewest: "Date (Newest)", collSortOldest: "Date (Oldest)", collSortTitle: "Title (A-Z)", collSortSite: "Site", collFolderAll: "All Folders", collSiteAll: "All Sites", collCheckHealth: "Check Health", collShowDupes: "Show Dupes", collBatchMerge: "Merge", collBatchMove: "Move to...", collBatchRemove: "Batch Remove", collBatchRemoveSuccess: "Batch removal completed", collImportJson: "Import JSON", collExportJson: "Export JSON", collUndo: "Undo", collMerging: "Merging...", collMoveTarget: "Enter folder name (Available: {folders})", collMoveSuccess: "Moved {count} items to {target}", collRemoveSuccess: "Removed from collection", collTotal: "Total", collShown: "Shown", labelError: "Error", confirmDelete: "Delete confirm?", confirmDeleteSelected: "Delete selected {count} items?", confirmForceSync: "Force full sync?", confirmDestructiveAction: "Warning: Irreversible action. Continue?", folderWanted: "Wanted", folderViewed: "Viewed", folderFollow: "Followed", labelAll: "All", titleEditMetadata: "Edit Metadata", labelRating: "Rating", labelNotes: "Notes", labelUserTags: "Tags", placeholderAddTag: "Enter tag...", alertMetadataSaved: "Metadata saved", collNewFolder: "New Folder...", collRenameFolder: "Rename Folder", collDeleteFolder: "Delete Folder", promptFolderName: "Enter folder name", promptNewFolderName: "Enter new folder name", confirmDeleteFolder: 'Delete folder "{folder}"? Items will remain in All.', alertFolderRenamed: "Folder renamed", collStatsRated: "Rated", collStatsAvg: "Avg", collStatsWithNotes: "With Notes", collStatsWithTags: "With Tags", collSortFolder: "By Folder", collHealthResult: "Health check: {checked} scanned, {repaired} repaired", collImportSuccess: "Imported {count} items", collImportPartial: "Imported {count} items, {errors} failed" } }; const Localization = { _translations: translations, t(key, params) { const lang = State.proxy.language === "auto" ? navigator.language.startsWith("zh") ? "zh" : "en" : State.proxy.language; const set = this._translations[lang] || this._translations["en"]; let value = set?.[key] || this._translations["en"]?.[key] || key; if (params && typeof value === "string") { Object.entries(params).forEach(([k, v]) => { value = value.replace(new RegExp(`{${k}}`, "g"), String(v)); }); } return value; }, resolvePath(obj, path) { try { return path.split(".").reduce((prev, curr) => { if (prev && typeof prev === "object") return prev[curr]; return void 0; }, obj); } catch { return null; } }, register(lang, newTranslations) { if (!this._translations[lang]) { this._translations[lang] = {}; } const tLang = this._translations[lang]; if (tLang) this.deepMerge(tLang, newTranslations); }, deepMerge(target, source) { for (const key in source) { if (source[key] instanceof Object && key in target && target[key] instanceof Object) { this.deepMerge(target[key], source[key]); } else { target[key] = source[key]; } } return target; } }; const t = (key, params) => Localization.t(key, params); const _isSearchPage = (() => { const p = location.pathname; const s = location.search; return p.includes("/search") || s.includes("s=") || s.includes("search") || s.includes("q=") || s.includes("keyword="); })(); const UIUtils = { h, icon: (svgContent, className = "") => { return h("span", { className: `fc2-icon ${className}`.trim(), innerHTML: svgContent }); }, hasHistory: (id, status) => { return HistoryService.has(id, status); }, copyButtonBehavior: (btn, textToCopy, i18nCopied) => { if (btn.dataset.copied === "true") return; Utils.copyToClipboard(textToCopy); btn.dataset.copied = "true"; const tt = btn.querySelector(`.${Config.CLASSES.tooltip}`); if (tt) { const originalTip = tt.textContent; tt.textContent = i18nCopied; setTimeout(() => { tt.textContent = originalTip; btn.dataset.copied = "false"; }, Config.COPIED_BADGE_DURATION); } else { const originalText = btn.textContent; btn.textContent = i18nCopied; setTimeout(() => { btn.textContent = originalText; btn.dataset.copied = "false"; }, Config.COPIED_BADGE_DURATION); } }, markByStatus: (id, status, el, applyVisibility) => { if (!State.proxy.enableHistory) return; HistoryService.add(id, status); const c = el.closest(`.${Config.CLASSES.processedCard}`) || el; if (c) { if (status === "blocked") { c.classList.add(Config.CLASSES.isBlocked); } else if (status === "wanted") { c.classList.add(Config.CLASSES.isWanted); } else if (status === "downloaded") { c.classList.add(Config.CLASSES.isDownloaded); } else { c.classList.add(Config.CLASSES.isViewed); } const vBtn = c.querySelector(".btn-toggle-view"); if (vBtn && status === "watched") vBtn.classList.add("is-viewed"); const oc = c.classList.contains(Config.CLASSES.cardRebuilt) ? c : c.closest(`.${Config.CLASSES.cardRebuilt}`); if (oc) { applyVisibility(oc); } } }, toggleLoading: (cont, show, btnCreator) => { if (!cont?.isConnected) return; const spinner = cont.querySelector(`.${Config.CLASSES.btnLoading}`); if (show) { if (!spinner) { const btn = btnCreator(IconSpinner, t("labelLoading"), "#"); btn.classList.add(Config.CLASSES.btnLoading); cont.appendChild(btn); } cont.classList.add("fc2-skeleton"); } else { if (spinner) spinner.remove(); cont.classList.remove("fc2-skeleton"); } }, applyCardVisibility: (c, hasM) => { if (!c) return; const target = c.classList.contains(Config.CLASSES.cardRebuilt) ? c : c.closest(`.${Config.CLASSES.cardRebuilt}`) || c; const processed = target.classList.contains(Config.CLASSES.processedCard) ? target : target.querySelector(`.${Config.CLASSES.processedCard}`); const isSearching = processed?.dataset?.enhSearching === "true"; const shouldHide = !_isSearchPage && State.proxy.hideNoMagnet && !hasM && !isSearching; target.classList.toggle(Config.CLASSES.hideNoMagnet, shouldHide); }, applyCensoredFilter: (c) => { if (!c) return; const target = c.classList.contains(Config.CLASSES.cardRebuilt) ? c : c.closest(`.${Config.CLASSES.cardRebuilt}`) || c; const isCensored = target.classList.contains(Config.CLASSES.isCensored) || !!target.querySelector(`.${Config.CLASSES.isCensored}`); const isSupjav = location.hostname.includes("supjav"); target.classList.toggle( Config.CLASSES.hideCensored, !_isSearchPage && isSupjav && State.proxy.hideCensored && isCensored ); }, applyHistoryVisibility: (c) => { if (!c) return; const target = c.classList.contains(Config.CLASSES.cardRebuilt) ? c : c.closest(`.${Config.CLASSES.cardRebuilt}`) || c; const isViewed = target.classList.contains(Config.CLASSES.isViewed) || !!target.querySelector(`.${Config.CLASSES.isViewed}`); target.classList.toggle(Config.CLASSES.hideViewed, !_isSearchPage && State.proxy.hideViewed && isViewed); target.classList.toggle( Config.CLASSES.hideBlocked, !_isSearchPage && target.classList.contains(Config.CLASSES.isBlocked) ); }, applyCollectionFilter: (c) => { if (!c) return; const target = c.classList.contains(Config.CLASSES.cardRebuilt) ? c : c.closest(`.${Config.CLASSES.cardRebuilt}`) || c; const isWanted = target.classList.contains(Config.CLASSES.isWanted) || !!target.querySelector(`.${Config.CLASSES.isWanted}`); target.classList.toggle("fc2-hide-unwanted", !_isSearchPage && State.proxy.hideUnwanted && !isWanted); } }; const tokens = ` /* ============================================================ DESIGN TOKENS & DESIGN SYSTEM ============================================================ */ :root, :host { /* --- Elevation & Depth --- */ --fc2-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --fc2-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); --fc2-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); --fc2-shadow-glow: none; /* --- Colors (Semantic / Catppuccin Base) --- */ --fc2-bg: #0a0a0c; --fc2-surface: rgba(22, 22, 26, 0.8); --fc2-surface-float: rgba(28, 28, 34, 0.92); --fc2-surface-low: rgba(255, 255, 255, 0.015); --fc2-surface-lowest: rgba(255, 255, 255, 0.01); --fc2-surface-item: rgba(255, 255, 255, 0.03); --fc2-text: #f4f4f7; --fc2-text-dim: #9494a0; --fc2-text-muted: #62626e; --fc2-border: rgba(255, 255, 255, 0.08); --fc2-primary: #ffffff; --fc2-on-primary: #000000; --fc2-primary-rgb: 255, 255, 255; --fc2-success: #34d399; --fc2-danger: #f87171; --fc2-warn: #fab387; --fc2-info: #89b4fa; --fc2-accent: #e4e4e7; /* --- Animation Curves --- */ --fc2-ease-out: cubic-bezier(0.16, 1, 0.3, 1); --fc2-ease-in: cubic-bezier(0.7, 0, 0.84, 0); --fc2-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --fc2-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* --- Spacing Scale (8px Grid) --- */ --fc2-space-xs: 4px; --fc2-space-sm: 8px; --fc2-space-base: 12px; --fc2-space-md: 16px; --fc2-space-lg: 24px; --fc2-space-xl: 32px; /* --- Gradients --- */ --fc2-accent-grad: linear-gradient(135deg, #3f3f46, #18181b); --fc2-magnet-grad: linear-gradient(135deg, #52525b, #27272a); /* --- Layout & Shape --- */ --fc2-radius-sm: 6px; --fc2-radius-md: 12px; --fc2-radius-lg: 16px; --fc2-radius-xl: 20px; --fc2-radius: var(--fc2-radius-lg); --fc2-radius-full: 9999px; --fc2-btn-radius: 10px; --fc2-blur: 0px; --fc2-glass-saturate: 100%; --fc2-font: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; --fc2-font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', monospace; /* --- Stealth Pro (Solid Black) --- */ --fc2-liquid-bg: #0a0a0c; /* Truly deep black base */ --fc2-liquid-border: 1px solid rgba(255, 255, 255, 0.08); --fc2-rim-light: inset 1px 1px 0px 0px rgba(255, 255, 255, 0.12), inset -1px -1px 0px 0px rgba(0, 0, 0, 0.3); --fc2-liquid-iridescent: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(180, 200, 255, 0.05) 30%, rgba(255, 180, 255, 0.04) 70%, rgba(255, 255, 255, 0.1) 100%); --fc2-glass-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); /* --- Global Component Styles --- */ --fc2-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); /* Z-Index Scale */ --fc2-z-toast: 2147483647; --fc2-z-gallery: 2147483645; --fc2-z-actionsheet: 2147483640; --fc2-z-fab: 2147483620; --fc2-z-modal: 2147483600; --fc2-z-settings: 2147483550; --fc2-z-overlay: 2147483500; --fc2-z-tooltip: 100000; --fc2-z-dropdown: 1000; /* Scrollbar Colors */ --fc2-scrollbar-thumb: rgba(255, 255, 255, 0.08); --fc2-scrollbar-hover: rgba(255, 255, 255, 0.15); /* Aliases for settings panel (Legacy Compatibility) */ --fc2-enh-bg: var(--fc2-liquid-bg); --fc2-enh-bg-secondary: rgba(18, 18, 20, 0.3); --fc2-enh-text: #f4f4f7; --fc2-enh-border: var(--fc2-liquid-border); } /* ============================================================ LIGHT THEME OVERRIDES ============================================================ */ :root.fc2-light-theme { --fc2-bg: #f8f9fa; --fc2-surface: rgba(255, 255, 255, 0.8); --fc2-text: #1a1a1a; --fc2-text-dim: #52525b; /* Darkened from #71717a for better contrast */ --fc2-border: rgba(0, 0, 0, 0.08); --fc2-primary: #111111; --fc2-on-primary: #ffffff; --fc2-success: #16a34a; --fc2-danger: #dc2626; --fc2-accent: #3f3f46; --fc2-accent-grad: linear-gradient(135deg, #e4e4e7, #f4f4f5); --fc2-magnet-grad: linear-gradient(135deg, #d4d4d8, #e4e4e7); --fc2-scrollbar-thumb: rgba(0, 0, 0, 0.15); --fc2-scrollbar-hover: rgba(0, 0, 0, 0.25); --fc2-shadow: 0 12px 32px rgba(0, 0, 0, 0.1); --fc2-surface-float: rgba(255, 255, 255, 0.92); --fc2-surface-low: rgba(0, 0, 0, 0.02); --fc2-surface-lowest: rgba(0, 0, 0, 0.01); --fc2-surface-item: rgba(0, 0, 0, 0.03); --fc2-liquid-bg: #f0f0f2; --fc2-liquid-border: 1px solid rgba(0, 0, 0, 0.08); --fc2-rim-light: inset 1px 1px 0px 0px rgba(255, 255, 255, 0.8), inset -1px -1px 0px 0px rgba(0, 0, 0, 0.05); --fc2-liquid-iridescent: linear-gradient(135deg, rgba(0, 0, 0, 0.03) 0%, rgba(0, 50, 100, 0.02) 30%, rgba(100, 0, 100, 0.02) 70%, rgba(0, 0, 0, 0.03) 100%); } `; const animations = ` /* ============================================================ GLOBAL ANIMATIONS ============================================================ */ /* Generic Fade & Slide In */ @keyframes fc2-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* Master Reveal Animation for Cards */ @keyframes fc2-card-reveal { 0% { opacity: 0; transform: translate3d(0, 12px, 0) scale(0.97); filter: blur(4px); } 100% { opacity: 1; transform: translate3d(0, 0, 0) scale(1); filter: blur(0); } } @keyframes fc2-content-reveal { 0% { opacity: 0; transform: translate3d(0, 0, 0); } 100% { opacity: 1; transform: translate3d(0, 0, 0); } } /* Core Spinner */ @keyframes fc2-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes fc2-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } @keyframes fc2-skeleton-pulse { 0% { opacity: 0.6; } 50% { opacity: 1; } 100% { opacity: 0.6; } } /* Pulse Effects */ @keyframes fc2-pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } } @keyframes fc2-pulse-sync { 0% { transform: scale(0.8); opacity: 0.6; } 50% { transform: scale(1.1); opacity: 1; } 100% { transform: scale(0.8); opacity: 0.6; } } @keyframes fc2-pulse-once { 0% { transform: scale(1); } 50% { transform: scale(1.15); background: var(--fc2-primary); color: #111; } 100% { transform: scale(1); } } /* UI Logic Specific */ @keyframes fc2-copy-success { 0% { transform: scale(1); } 50% { transform: scale(1.1); background: var(--fc2-success); } 100% { transform: scale(1); } } @keyframes fc2-magnet-in { 0% { transform: scale(0.3) translateY(20px); opacity: 0; filter: blur(5px); } 60% { transform: scale(1.1) translateY(-5px); filter: blur(0); } 85% { transform: scale(0.95) translateY(2px); } 100% { transform: scale(1) translateY(0); opacity: 1; } } @keyframes fc2-pop-in { 0% { opacity: 0; transform: translate(-50%, -45%) scale(0.92); filter: blur(10px); } 70% { transform: translate(-50%, -51%) scale(1.02); filter: blur(0); } 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); } } /* Gallery & Media Animations */ @keyframes fc2-gallery-zoom { 0% { opacity: 0; transform: scale(0.95); } 100% { opacity: 1; transform: scale(1); } } @keyframes fc2-slide-right-in { 0% { opacity: 0; transform: translateX(30px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes fc2-slide-left-in { 0% { opacity: 0; transform: translateX(-30px); } 100% { opacity: 1; transform: translateX(0); } } @keyframes fc2-animate-pop { 0% { transform: scale(1); } 50% { transform: scale(1.25); } 100% { transform: scale(1); } } @keyframes fc2-star-burst { 0% { transform: scale(0); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } } @keyframes fc2-glow-pulse { 0% { filter: drop-shadow(0 0 2px var(--fc2-primary)); } 50% { filter: drop-shadow(0 0 10px var(--fc2-primary)); } 100% { filter: drop-shadow(0 0 2px var(--fc2-primary)); } } /* Helper Classes */ .pulse-once { animation: fc2-pulse-once 0.6s var(--fc2-ease-out); } .fc2-animate-pop { animation: fc2-animate-pop 0.3s var(--fc2-ease-out); } .fc2-star-burst::after { content: ''; position: absolute; inset: -10px; border: 2px solid var(--fc2-primary); border-radius: 50%; animation: fc2-star-burst 0.5s ease-out forwards; pointer-events: none; } `; const getBaseStyles = (C) => ` /* ============================================================ CSS RESET & GLOBAL OVERRIDES ============================================================ */ .fc2-enh-settings-panel, .fc2-enh-modal-overlay, .enh-modal-panel, .fc2-action-sheet, .fc2-collection-grid, .fc2-collection-toolbar, .\${C.cardRebuilt}, .\${C.processedCard}, .fc2-fab-container { all: revert; box-sizing: border-box; -webkit-tap-highlight-color: transparent !important; -webkit-touch-callout: none !important; transition: background-color 0.4s var(--fc2-ease-standard), color 0.4s var(--fc2-ease-standard), border-color 0.4s var(--fc2-ease-standard); } .fc2-enh-settings-panel *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .fc2-enh-modal-overlay *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .enh-modal-panel *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .fc2-fab-container *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .fc2-action-sheet *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .fc2-collection-grid *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .fc2-collection-toolbar *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .\${C.cardRebuilt} *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon), .\${C.processedCard} *:not(svg):not(path):not(circle):not(rect):not(line):not(polyline):not(polygon) { box-sizing: border-box; font-family: var(--fc2-font) !important; font-size: 14px !important; font-weight: normal !important; font-style: normal !important; line-height: 1.5 !important; letter-spacing: normal !important; text-transform: none !important; } /* Icons Visibility Fix */ .fc2-enh-settings-panel svg, .fc2-enh-settings-panel .fc2-icon { display: inline-block !important; vertical-align: middle !important; } /* Headings */ .fc2-enh-settings-panel h2, .fc2-enh-settings-panel h3, .fc2-enh-settings-panel h4 { margin: 0 !important; padding: 0 !important; font-weight: 600 !important; } .fc2-enh-settings-panel h2 { font-size: 20px !important; } .fc2-enh-settings-panel h3 { font-size: 16px !important; } .fc2-enh-settings-panel h4 { font-size: 14px !important; } /* Forms & Controls */ .fc2-enh-settings-panel label, .fc2-enh-settings-panel input, .fc2-enh-settings-panel select, .fc2-enh-settings-panel button { font-size: 14px !important; line-height: 1.5 !important; } /* ============================================================ SELECT DROPDOWN STYLES ============================================================ */ .fc2-enh-settings-panel select, .fc2-enh-settings-panel .fc2-select { display: inline-block !important; padding: 6px 32px 6px 12px !important; background: var(--fc2-surface-float) !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 512'%3E%3Cpath fill='%23ffffff' d='M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z'/%3E%3C/svg%3E") !important; background-repeat: no-repeat !important; background-position: right 8px center !important; background-size: 12px !important; color: var(--fc2-text) !important; border: 1px solid var(--fc2-border) !important; border-radius: var(--fc2-radius-sm) !important; cursor: pointer !important; appearance: none !important; -webkit-appearance: none !important; color-scheme: dark !important; filter: invert(0) !important; transition: all 0.2s var(--fc2-ease-standard); } .fc2-enh-settings-panel select:hover { border-color: var(--fc2-text-dim) !important; background-color: rgba(255, 255, 255, 0.05) !important; } .fc2-enh-settings-panel select:focus { outline: none !important; border-color: var(--fc2-primary) !important; box-shadow: var(--fc2-shadow-glow) !important; } .fc2-enh-settings-panel select option { padding: 6px 12px !important; background: var(--fc2-bg) !important; color: var(--fc2-text) !important; } .fc2-enh-settings-panel select option:checked, .fc2-enh-settings-panel select option:hover { background: var(--fc2-primary) !important; color: var(--fc2-text) !important; } /* ============================================================ SCROLLBAR & UTILITIES ============================================================ */ /* Global Scrollbar */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--fc2-scrollbar-thumb) !important; border-radius: 10px; } ::-webkit-scrollbar-thumb:hover { background: var(--fc2-scrollbar-hover) !important; } /* Functional Hide Classes */ .${C.hideNoMagnet}, .${C.hideCensored}, .${C.hideViewed}, .is-hidden { display: none !important; } .is-invisible { visibility: hidden !important; } body.fc2-settings-open { overflow: hidden !important; } .fc2-ml-sm { margin-left: 8px !important; } #fc2-enh-settings-host { position: fixed; inset: 0; z-index: var(--fc2-z-settings); } #fc2-enh-settings-container { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--fc2-font); } `; const base = (_C) => ` /* ============================================================ BASE COMPONENTS & RESET ============================================================ */ /* Global Mono Penetration for industrial feel */ .fc2-stat-value, .fc2-id-badge, .fc2-stat-label, .fc2-password-wrapper input, #debug-log-list { font-family:var(--fc2-font-mono)!important; letter-spacing:-0.02em; } *,::before,::after { box-sizing:border-box; } .fc2-icon { display:inline-flex; align-items:center; justify-content:center; width:1em; height:1em; vertical-align:-0.125em; } .fc2-icon svg { width:100%; height:100%; fill:currentColor; } .fc2-icon-dropdown-caret { display:inline-flex; margin-left:var(--fc2-space-xs); opacity:0.6; font-size:10px; width:1em; height:1em; } .fc2-icon-dropdown-caret svg { fill:currentColor; } .dim { opacity:0.5; color:var(--fc2-text-muted); } /* ── Utility Classes ───────────────────────────────────────── */ .fc2-mt-sm { margin-top:0.5rem; } .fc2-gap-sm { gap:8px; } `; const toast = (_C) => ` /* ============================================================ TOAST NOTIFICATIONS ============================================================ */ .fc2-toast-container { position:fixed; top:var(--fc2-space-md); right:var(--fc2-space-md); z-index:var(--fc2-z-toast); display:flex; flex-direction:column; gap:var(--fc2-space-sm); pointer-events:none; } .fc2-toast-item { display:flex; align-items:center; min-width:280px; padding:var(--fc2-space-sm) var(--fc2-space-md); background:var(--fc2-surface-float); color:var(--fc2-text); border-radius:var(--fc2-radius-md); box-shadow:var(--fc2-shadow-lg); font-size:14px; font-weight:500; backdrop-filter:blur(var(--fc2-blur)); -webkit-backdrop-filter:blur(var(--fc2-blur)); border:1px solid var(--fc2-border); opacity:0; pointer-events:auto; animation:fc2-toast-in 0.25s var(--fc2-ease-out) forwards; } .fc2-toast-item.hiding { animation:fc2-toast-out 0.4s var(--fc2-ease-standard) forwards; } .fc2-toast-icon { display:flex; align-items:center; justify-content:center; font-size:1.25rem; margin-right:var(--fc2-space-sm); flex-shrink:0; } .fc2-toast-content { flex-grow:1; line-height:1.4; } .fc2-toast-close { background:none; border:none; color:var(--fc2-text-dim); cursor:pointer; padding:var(--fc2-space-xs); margin-left:var(--fc2-space-sm); border-radius:var(--fc2-radius-full); transition:all 0.2s; display:flex; align-items:center; } .fc2-toast-close:hover { background:rgba(255,255,255,0.1); color:var(--fc2-text); } .fc2-toast-progress { position:absolute; bottom:0; left:0; height:2px; width:100%; transform-origin:left; } .toast-success { border-left:4px solid var(--fc2-success); } .toast-success.fc2-toast-icon { color:var(--fc2-success); } .toast-error { border-left:4px solid var(--fc2-danger); } .toast-error.fc2-toast-icon { color:var(--fc2-danger); } .toast-warn { border-left:4px solid var(--fc2-warn); } .toast-warn.fc2-toast-icon { color:var(--fc2-warn); } .toast-info { border-left:4px solid var(--fc2-info); } .toast-info.fc2-toast-icon { color:var(--fc2-info); } @keyframes fc2-toast-shrink { from { width:100%; } to { width:0%; } } .fc2-toast-action { margin-left:var(--fc2-space-sm); padding:2px 8px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:4px; color:var(--fc2-primary); font-size:12px; font-weight:600; cursor:pointer; transition:all 0.2s; } .fc2-toast-action:hover { background:var(--fc2-primary); color:var(--fc2-on-primary); border-color:transparent; } `; const fab = (_C) => ` /* ============================================================ ELEGANT FAB (UNIFIED) ============================================================ */ .fc2-fab-container { position:fixed; bottom:20px; right:20px; z-index:var(--fc2-z-fab); display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:12px; pointer-events:none; max-height:90vh; } .fc2-fab-trigger, .fc2-fab-actions { pointer-events:auto; } .fc2-fab-trigger { display:flex; align-items:center; justify-content:center; width:48px; height:48px; background:var(--fc2-accent-grad); color:var(--fc2-text); border:var(--fc2-liquid-border); border-radius:50%; box-shadow:var(--fc2-shadow),var(--fc2-rim-light); font-size:20px; cursor:pointer; transition:all 0.3s var(--fc2-ease-out); touch-action:none; -webkit-tap-highlight-color:transparent; will-change:transform; backdrop-filter:blur(12px); } .fc2-fab-trigger:hover { transform:scale(1.12); box-shadow:0 15px 35px rgba(0,0,0,0.4); } .fc2-fab-trigger:active { transform:scale(0.92); } .fc2-fab-trigger.active { transform:rotate(135deg); background:var(--fc2-primary); color:var(--fc2-on-primary); animation:fc2-float 3s ease-in-out infinite; } .fc2-fab-trigger.active:hover { transform:scale(1.1) rotate(135deg); animation-play-state:paused; } .fc2-fab-actions { display:flex; flex-direction:column; gap:var(--fc2-space-base); opacity:0; transform:translateY(20px) scale(0.8); pointer-events:none; transition:all 0.4s var(--fc2-ease-out); max-height:60vh; width:64px; overflow-y:auto!important; overflow-x:hidden; padding:10px; margin-bottom:8px; background:var(--fc2-liquid-bg); backdrop-filter:blur(var(--fc2-blur)) saturate(var(--fc2-glass-saturate)); -webkit-backdrop-filter:blur(var(--fc2-blur)) saturate(var(--fc2-glass-saturate)); border:var(--fc2-liquid-border); box-shadow:var(--fc2-shadow),var(--fc2-rim-light); border-radius:var(--fc2-radius-lg); scrollbar-width:thin; scrollbar-color:var(--fc2-primary) transparent; touch-action:pan-y; position:relative; } .fc2-fab-actions::before { content:""; position:absolute; inset:0; background:var(--fc2-glass-noise); opacity:0.05; pointer-events:none; z-index:0; } .fc2-fab-actions::-webkit-scrollbar { width:6px; display:block!important; } .fc2-fab-actions::-webkit-scrollbar-track { background:transparent; } .fc2-fab-actions::-webkit-scrollbar-thumb { background-color:var(--fc2-primary); border-radius:10px; border:1px solid rgba(255,255,255,0.1); } .fc2-fab-actions.visible { opacity:1; transform:translateY(0) scale(1); pointer-events:auto; } .fc2-fab-actions.visible .fc2-fab-btn:nth-child(1) { transition-delay:0.05s; } .fc2-fab-actions.visible .fc2-fab-btn:nth-child(2) { transition-delay:0.1s; } .fc2-fab-actions.visible .fc2-fab-btn:nth-child(3) { transition-delay:0.15s; } .fc2-fab-actions.visible .fc2-fab-btn:nth-child(4) { transition-delay:0.2s; } .fc2-fab-actions.visible .fc2-fab-btn:nth-child(5) { transition-delay:0.25s; } .fc2-fab-btn { position:relative; display:flex; align-items:center; justify-content:center; width:44px; height:44px; background:rgba(255,255,255,0.05); color:var(--fc2-text-dim); border:1px solid rgba(255,255,255,0.08); border-radius:50%; font-size:16px; cursor:pointer; transition:all 0.25s var(--fc2-ease-out); -webkit-tap-highlight-color:transparent; z-index:2; } .fc2-fab-btn:hover { background:rgba(255,255,255,0.12); color:var(--fc2-text); border-color:rgba(255,255,255,0.2); transform:scale(1.15); box-shadow:0 10px 20px rgba(0,0,0,0.3); } .fc2-fab-btn.active { background:var(--fc2-primary); color:var(--fc2-on-primary); border-color:transparent; box-shadow:0 0 20px rgba(var(--fc2-primary-rgb),0.4); } .fc2-fab-btn::before { content:attr(data-title); position:absolute; right:52px; top:50%; visibility:hidden; padding:var(--fc2-space-xs) var(--fc2-space-sm); background:rgba(0,0,0,0.85); color:#fff; border-radius:var(--fc2-radius-sm); font-size:12px; white-space:nowrap; opacity:0; backdrop-filter:blur(4px); transform:translateY(-50%) translateX(5px); transition:all 0.2s; pointer-events:none; } .fc2-fab-btn:hover::before { visibility:visible; opacity:1; transform:translateY(-50%) translateX(0); } .fc2-sync-dot { position:absolute; top:-2px; right:-2px; width:10px; height:10px; background:var(--fc2-text-muted); border:2px solid var(--fc2-bg); border-radius:50%; transition:all 0.3s; } .fc2-sync-dot.syncing { background:var(--fc2-info); animation:fc2-pulse-sync 1.5s infinite; } .fc2-sync-dot.success { background:var(--fc2-success); } .fc2-sync-dot.error { background:var(--fc2-danger); } .fc2-sync-dot.conflict { background:var(--fc2-warn); } `; const card = (C) => ` /* ============================================================ CARD SYSTEM ============================================================ */ .${C.cardRebuilt} { position:relative; background:var(--fc2-surface); border:1px solid var(--fc2-border); border-radius:var(--fc2-radius); animation:fc2-card-reveal 0.35s var(--fc2-ease-out) backwards!important; will-change: transform, opacity; container-type:inline-size; container-name:card; transition:none!important; } .${C.cardRebuilt}.has-active-dropdown { z-index:var(--fc2-z-dropdown)!important; } /* Global Visibility Toggles */ body.hide-viewed-btn .btn-toggle-view { display:none!important; } body.hide-id-badge .${C.fc2IdBadge} { display:none!important; } /* Critical:Hide Viewed Cards */ body.hide-viewed .${C.processedCard}.${C.isViewed} { display:none!important; } body.searching .${C.cardRebuilt}:not(.search-match) { display:none!important; } body.searching .${C.cardRebuilt}.search-match { animation:fc2-fade-in-scale 0.4s var(--fc2-ease-out); } .${C.cardRebuilt}.fc2-hide-unwanted { display:none!important; } .${C.processedCard} { position:relative; display:flex; flex-direction:column; height:100%; background:var(--fc2-surface); border:1px solid var(--fc2-border); border-radius:var(--fc2-radius-lg); overflow:visible; transition:transform 0.4s var(--fc2-ease-out), box-shadow 0.4s var(--fc2-ease-out), border-color 0.3s ease; } .${C.processedCard}:hover { transform:translateY(-8px) scale(1.02); border-color:rgba(var(--fc2-primary-rgb),0.5); box-shadow:0 20px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1); z-index:20; } .${C.processedCard}.has-active-dropdown, .${C.cardRebuilt}.has-active-dropdown { z-index:100!important; overflow:visible!important; } .${C.processedCard}.${C.isViewed} { border-color:var(--fc2-accent); } /* Detail Page Poster Style (Vertical) */ .${C.processedCard}.is-detail { width:100%!important; max-width:none!important; height:auto!important; } .${C.processedCard}.is-detail .${C.videoPreviewContainer} { aspect-ratio:auto!important; height:auto!important; background:transparent!important; } .${C.processedCard}.is-detail .${C.videoPreviewContainer} img { position:static!important; display:block!important; width:100%!important; height:auto!important; } .${C.processedCard}.is-detail .${C.infoArea} { padding:10px var(--fc2-space-base)!important; margin-top:0!important; } .${C.processedCard}.is-detail .${C.resourceLinksContainer} { margin-top:0!important; } /* Minimal Card (Collection Tab) */ .${C.processedCard}.is-minimal { height:auto!important; min-height:0; padding-top:0!important; aspect-ratio:auto!important; background:var(--fc2-surface)!important; border-radius:var(--fc2-radius-md)!important; overflow:hidden; } .${C.processedCard}.is-minimal .${C.videoPreviewContainer} { display:block!important; } .${C.processedCard}.is-minimal .card-top-right-controls { top:6px!important; right:6px!important; } .${C.processedCard}.is-minimal .card-top-right-controls > *:not(.btn-toggle-wanted) { display:none!important; } .${C.processedCard}.is-minimal .${C.fc2IdBadge} { display:none!important; } .${C.processedCard}.is-minimal .${C.infoArea} { padding:4px 10px 8px!important; background:transparent!important; border-top:none!important; } .${C.processedCard}.is-minimal .${C.customTitle} { height:auto!important; -webkit-line-clamp:1!important; margin-bottom:0!important; padding-right:28px!important; font-size:12px!important; } .${C.processedCard}.is-minimal .card-left-actions { display:none!important; } .${C.videoPreviewContainer} { position:relative; width:100%; aspect-ratio:16 / 9; background:var(--fc2-bg); border-top-left-radius:var(--fc2-radius-lg); border-top-right-radius:var(--fc2-radius-lg); overflow:hidden; } .${C.videoPreviewContainer} video, .${C.videoPreviewContainer} img.${C.staticPreview} { width:100%; height:100%; object-fit:contain; transition:opacity 0.25s var(--fc2-ease-out)!important; opacity:0; position:absolute!important; top:0!important; left:0!important; transform:translate3d(0,0,0); } .${C.videoPreviewContainer} .fc2-reveal-content:not(.${C.hidden}) { animation:fc2-content-reveal 0.4s var(--fc2-ease-out) forwards!important; } .${C.videoPreviewContainer}:not(.fc2-preview-active) img.${C.staticPreview}:not(.${C.hidden}) { animation:none!important; opacity:1!important; filter:none!important; transform:translate3d(0,0,0)!important; } .${C.videoPreviewContainer}:not(.fc2-preview-active) video { opacity:0!important; pointer-events:none!important; } .${C.videoPreviewContainer} .${C.hidden} { opacity:0!important; pointer-events:none!important; } .${C.processedCard}:hover .${C.videoPreviewContainer} video, .${C.processedCard}:hover .${C.videoPreviewContainer} img.${C.staticPreview} { transform:translate3d(0,0,0)!important; } .${C.previewElement} { position:absolute; top:0; left:0; opacity:1; transition:opacity 0.4s var(--fc2-ease-standard); } .${C.previewElement}.${C.hidden} { opacity:0; pointer-events:none; } .${C.infoArea} { display:flex; flex-direction:column; justify-content:flex-end; flex-grow:1; padding:var(--fc2-space-base); background:rgba(255,255,255,0.03); border-top:1px solid var(--fc2-border); border-bottom-left-radius:var(--fc2-radius); border-bottom-right-radius:var(--fc2-radius); } .${C.customTitle} { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; height:38px; margin:0 0 var(--fc2-space-sm); color:var(--fc2-text)!important; font-size:13px; font-weight:600!important; line-height:1.5; text-decoration:none!important; overflow:hidden; transition:color 0.2s; } .${C.customTitle}:hover { color:var(--fc2-primary)!important; } .card-left-actions { display:flex; align-items:center; justify-content:flex-start; gap:var(--fc2-space-xs); margin-top:auto; } .${C.resourceLinksContainer} { display:flex; align-items:center; justify-content:flex-end; gap:var(--fc2-space-xs); margin-left:auto; } /* Visibility Control */ .${C.processedCard}.${C.hideNoMagnet} { display:none!important; } .${C.processedCard}.${C.hideCensored} { display:none!important; } .${C.processedCard}.${C.hideBlocked} { display:none!important; } `; const button = (C) => ` /* ============================================================ UNIFIED BUTTON SYSTEM ============================================================ */ .card-top-right-controls { position:absolute; top:var(--fc2-space-sm); right:var(--fc2-space-sm); z-index:10; display:flex; gap:var(--fc2-space-xs); align-items:center; } .${C.resourceBtn}, .card-top-right-controls > *, .verify-cf-btn { position:relative; display:inline-flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.25); color:rgba(255,255,255,0.9); border:1px solid rgba(255,255,255,0.15); border-radius:var(--fc2-btn-radius); font-size:13px; font-weight:500; text-decoration:none; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); cursor:pointer; transition:all 0.3s var(--fc2-ease-out); } .${C.resourceBtn} *, .fc2-fab-btn *, .fc2-fab-trigger *, .close-btn *, .verify-cf-btn * { pointer-events:none!important; } .${C.resourceBtn}:hover, .card-top-right-controls > *:hover { background:var(--fc2-magnet-grad); color:#fff; border-color:transparent; transform:translateY(-2px) scale(1.02); box-shadow:var(--fc2-shadow-lg); } .${C.resourceBtn}:active, .card-top-right-controls > *:active { transform:translateY(0) scale(0.98); } /* Micro-Interactions */ .${C.resourceBtn}::after, .fc2-fab-btn::after, .fc2-enh-btn::after, .fc2-btn::after { content:""; position:absolute; top:50%; left:50%; width:100%; height:100%; background:rgba(var(--fc2-primary-rgb),0.2); opacity:0; border-radius:inherit; transform:translate(-50%,-50%) scale(1); pointer-events:none; transition:opacity 0.3s; } .${C.resourceBtn}:active::after, .fc2-fab-btn:active::after, .fc2-btn:active::after { animation:fc2-ripple 0.4s var(--fc2-ease-standard); opacity:1; } .verify-cf-btn { margin:var(--fc2-space-xs) auto; padding:var(--fc2-space-xs) var(--fc2-space-md); color:var(--fc2-warn)!important; border-color:rgba(250,179,135,0.3)!important; } .verify-cf-btn:hover { background:rgba(250,179,135,0.2)!important; color:var(--fc2-text)!important; border-color:var(--fc2-warn)!important; transform:translateY(-2px); box-shadow:0 4px 15px rgba(250,179,135,0.2)!important; } .card-top-right-controls > * { height:24px; padding:0 6px; font-size:10px; } .${C.resourceBtn} { height:32px; padding:0 12px; font-size:13px; } @container(max-width:250px) { .card-top-right-controls > * { height:20px; padding:0 4px; font-size:9px; } .card-top-right-controls { top:6px; right:6px; gap:3px; } .${C.resourceBtn} { height:24px; padding:0 6px; font-size:10px; } } @container(min-width:350px) { .card-top-right-controls > * { height:26px; padding:0 8px; font-size:11px; } .${C.resourceBtn} { height:30px; padding:0 12px; font-size:13px; } } .${C.resourceBtn}.${C.btnMagnet} { font-weight:600; margin-left:auto; background:rgba(0,0,0,0.25); border:1px solid rgba(255,255,255,0.15); animation:fc2-magnet-in 0.4s var(--fc2-ease-spring); } .btn-toggle-view.is-viewed { color:var(--fc2-primary); border-color:var(--fc2-primary); } .btn-toggle-view:not(.is-viewed) .icon-viewed { display:none; } .btn-toggle-view:not(.is-viewed) .icon-unviewed { display:inline-flex; } .btn-toggle-view.is-viewed .icon-viewed { display:inline-flex; } .btn-toggle-view.is-viewed .icon-unviewed { display:none; } .btn-toggle-wanted:not(.is-wanted) .icon-wanted { display:none; } .btn-toggle-wanted:not(.is-wanted) .icon-unwanted { display:inline-flex; color:rgba(255,255,255,0.7); } .btn-toggle-wanted.is-wanted .icon-wanted { display:inline-flex; } .btn-toggle-wanted.is-wanted .icon-unwanted { display:none; } .btn-toggle-wanted.is-wanted { color:var(--fc2-accent); background:rgba(241,196,15,0.1); border-color:rgba(241,196,15,0.4); text-shadow:0 0 10px rgba(241,196,15,0.5); } .btn-toggle-wanted.fc2-star-burst::after { border-color: var(--fc2-accent); } /* 隐藏所有功能按钮的文字以保持图标化风格,同时覆盖工具栏和卡片 */ .${C.processedCard} .${C.resourceBtn} .${C.buttonText}, .enh-toolbar .${C.resourceBtn} .${C.buttonText} { display: none !important; } /* 确保 ID 徽章文字在任何容器下都保持显示 */ .${C.processedCard} .${C.resourceBtn}.${C.fc2IdBadge} .${C.buttonText}, .enh-toolbar .${C.resourceBtn}.${C.fc2IdBadge} .${C.buttonText} { display: inline-block !important; } .btn-toggle-blocked.is-blocked { color:var(--fc2-danger); border-color:rgba(243,139,168,0.4); } .btn-actress { display:inline-flex; align-items:center; justify-content:center; margin:var(--fc2-space-xs) auto; padding:var(--fc2-space-xs) var(--fc2-space-md); background:rgba(0,0,0,0.25); color:rgba(255,255,255,0.9); border:1px solid rgba(255,255,255,0.15); border-radius:var(--fc2-btn-radius); font-weight:500; backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); cursor:pointer; transition:all 0.3s var(--fc2-ease-out); } .btn-actress:hover { background:var(--fc2-magnet-grad); color:#fff; border-color:transparent; transform:translateY(-2px) scale(1.02); box-shadow:var(--fc2-shadow-lg); } .${C.fc2IdBadge} { font-family:var(--fc2-font-mono); letter-spacing:0.5px; } .${C.fc2IdBadge} .${C.buttonText} { font-weight:600; } .${C.fc2IdBadge}:hover { border-color:rgba(255,255,255,0.4); } .${C.fc2IdBadge}.${C.badgeCopied} { background:var(--fc2-success)!important; color:var(--fc2-on-primary)!important; border-color:var(--fc2-success); font-weight:700!important; animation:fc2-copy-success 0.4s ease; } /* Button Industrial Style Override */ .fc2-btn-industrial { position:relative; overflow:hidden; border:1px solid rgba(255,255,255,0.1)!important; background:rgba(255,255,255,0.03)!important; transition:all 0.2s var(--fc2-ease-out)!important; } .fc2-btn-industrial:active { transform:scale(0.97) translateY(2px)!important; background:rgba(0,0,0,0.2)!important; box-shadow:inset 0 2px 4px rgba(0,0,0,0.3)!important; } .fc2-btn-industrial.primary { background:rgba(var(--fc2-primary-rgb),0.1)!important; border-color:rgba(var(--fc2-primary-rgb),0.2)!important; color:var(--fc2-primary)!important; } .fc2-enh-btn, .fc2-btn { height:38px; padding:0 1.5rem; background:rgba(255,255,255,0.05); color:var(--fc2-text-dim); border:1px solid var(--fc2-border); border-radius:var(--fc2-btn-radius); font-family:inherit; font-size:0.9rem; font-weight:600; cursor:pointer; transition:all 0.2s var(--fc2-ease-standard); display:inline-flex; align-items:center; justify-content:center; white-space:nowrap; } .fc2-enh-btn:hover, .fc2-btn:hover { background:rgba(255,255,255,0.1); color:var(--fc2-text); border-color:rgba(255,255,255,0.15); } .fc2-enh-btn.ghost, .fc2-btn.ghost { background:transparent; border-color:transparent; color:var(--fc2-text-dim); } .fc2-enh-btn.ghost:hover, .fc2-btn.ghost:hover { background:rgba(255,255,255,0.05); color:var(--fc2-text); } .fc2-enh-btn.micro, .fc2-btn.micro { height:28px; padding:0 0.75rem; font-size:12px!important; font-weight:500!important; } .fc2-enh-btn.icon-only, .fc2-btn.icon-only { width:38px; padding:0; justify-content:center; } .fc2-enh-btn.active, .fc2-btn.active { background:rgba(var(--fc2-primary-rgb),0.15); color:var(--fc2-primary); border-color:rgba(var(--fc2-primary-rgb),0.3); } .fc2-enh-btn:disabled, .fc2-btn:disabled { opacity:0.4; cursor:not-allowed; pointer-events:none; } .fc2-enh-btn.primary, .fc2-btn.primary { background:var(--fc2-primary); color:var(--fc2-on-primary); border-color:transparent; font-weight:700; } .fc2-enh-btn.primary:hover, .fc2-btn.primary:hover { background:var(--fc2-text); transform:scale(1.02); box-shadow:0 4px 12px rgba(255,255,255,0.1); } .fc2-enh-btn.danger, .fc2-btn.danger { color:var(--fc2-danger); border-color:rgba(248,113,113,0.2); } .fc2-enh-btn.danger:hover, .fc2-btn.danger:hover { background:rgba(248,113,113,0.08); border-color:var(--fc2-danger); } /* Industrial feedback for buttons in panel */ .fc2-enh-settings-panel .fc2-enh-btn.primary, .fc2-enh-settings-panel .fc2-btn.primary, .fc2-enh-settings-panel .fc2-enh-btn.danger, .fc2-enh-settings-panel .fc2-btn.danger { position:relative; overflow:hidden; transition:all 0.2s var(--fc2-ease-out)!important; } .fc2-enh-settings-panel .fc2-enh-btn.primary:active, .fc2-enh-settings-panel .fc2-btn.primary:active, .fc2-enh-settings-panel .fc2-enh-btn.danger:active, .fc2-enh-settings-panel .fc2-btn.danger:active { transform:scale(0.97) translateY(1px); box-shadow:inset 0 2px 4px rgba(0,0,0,0.2); } `; const form = (_C) => ` /* ============================================================ FORM ELEMENTS (GLASSMORPHISM) ============================================================ */ .fc2-enh-form-row { margin-bottom:var(--fc2-space-md); } .fc2-enh-label { display:block; margin-bottom:4px; color:var(--fc2-text-dim); font-size:13px; font-weight:500; } .fc2-enh-select, .fc2-enh-input, .fc2-enh-textarea { width:100%; padding:10px 14px; background:var(--fc2-bg)!important; color:var(--fc2-text)!important; border:1px solid rgba(255,255,255,0.08); border-radius:var(--fc2-btn-radius); font-family:inherit; font-size:14px; outline:none; transition:all 0.3s var(--fc2-ease-out); appearance:none; -webkit-appearance:none; will-change: border-color, box-shadow, background-color; } .fc2-enh-select option { background:var(--fc2-bg); color:var(--fc2-text); padding:10px; } .fc2-enh-select option:checked, .fc2-enh-select option:hover { background:var(--fc2-primary)!important; color:var(--fc2-on-primary)!important; } .fc2-enh-select:focus, .fc2-enh-input:focus, .fc2-enh-textarea:focus { border-color:var(--fc2-primary); background:var(--fc2-surface)!important; box-shadow:0 0 0 3px rgba(var(--fc2-primary-rgb),0.1); } .fc2-enh-checkbox-label { display:flex; align-items:center; gap:10px; cursor:pointer; user-select:none; padding:4px 0; } input[type="checkbox"] { position:relative; appearance:none; -webkit-appearance:none; width:1.25rem; height:1.25rem; background:var(--fc2-surface-lowest); border:1px solid var(--fc2-border); border-radius:4px; cursor:pointer; transition:all 0.2s var(--fc2-ease-out); display:flex; align-items:center; justify-content:center; } input[type="checkbox"]:checked { background:var(--fc2-primary); border-color:var(--fc2-primary); } input[type="checkbox"]::after { content:""; width:10px; height:10px; background-color:#000; clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0%,43% 62%); transform:scale(0); transition:transform 0.2s var(--fc2-ease-spring); } input[type="checkbox"]:checked::after { transform:scale(1); } .fc2-enh-checkbox-text { font-size:14px; color:var(--fc2-text); font-weight:600; opacity:0.9; } /* Range Input */ .fc2-enh-range { -webkit-appearance:none; width:100%; height:4px; background:rgba(255,255,255,0.1); border-radius:2px; outline:none; } .fc2-enh-range::-webkit-slider-thumb { -webkit-appearance:none; width:14px; height:14px; border-radius:50%; background:var(--fc2-primary); cursor:pointer; box-shadow:0 0 0 2px var(--fc2-bg); } .fc2-enh-form-row.checkbox { justify-content:flex-start; gap:10px; padding:0.75rem 1rem; margin:0; border:1px solid var(--fc2-border); border-radius:var(--fc2-btn-radius); background:rgba(255, 255, 255, 0.02); transition:all 0.2s var(--fc2-ease-out); cursor:pointer; width:100%; box-sizing:border-box; will-change: background-color, border-color, box-shadow; } .fc2-enh-form-row.checkbox:hover { background:rgba(255,255,255,0.06); border-color:rgba(255, 255, 255, 0.2); } .fc2-enh-form-row.checkbox:has(input[type="checkbox"]:checked) { background: rgba(255, 255, 255, 0.08); border-color: var(--fc2-primary); box-shadow: 0 0 15px rgba(255, 255, 255, 0.05); } .fc2-enh-form-row.checkbox:has(input[type="checkbox"]:checked) .fc2-enh-checkbox-text { opacity: 1; font-weight: 500; color: var(--fc2-text) !important; } .fc2-input-group { display:flex; gap:8px; align-items:center; width:100%; } .fc2-input-group .fc2-enh-input { flex:1; min-width:0; } .fc2-input-toggle { display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; flex-shrink:0; padding:0; background:rgba(255,255,255,0.05); color:var(--fc2-text-dim); border:1px solid var(--fc2-border); border-radius:var(--fc2-btn-radius); cursor:pointer; transition:all 0.2s var(--fc2-ease-standard); } .fc2-input-toggle:hover { background:rgba(255,255,255,0.1); color:var(--fc2-text); } .fc2-input-toggle svg { width:14px; height:14px; fill:currentColor; } /* Hide Native Edge/IE reveal icons as we use our own */ input[type="password"]::-ms-reveal, input[type="password"]::-ms-clear { display: none !important; } `; const tabs = (_C) => ` /* ============================================================ TABS (SIDEBAR STYLE) ============================================================ */ .fc2-enh-settings-tabs { display:flex; flex-direction:column; width:180px; padding:1.5rem 1.25rem; background:rgba(0,0,0,0.15); border-right:1px solid rgba(255,255,255,0.05); flex-shrink:0; gap:6px; overflow-y:auto; transition:width 0.4s var(--fc2-ease-out); } .fc2-enh-tab-btn { display:flex; align-items:center; gap:10px; width:100%; padding:10px 12px; background:transparent; color:var(--fc2-text-dim); border:none; border-radius:var(--fc2-radius-md); font-size:14px; font-weight:500; text-align:left; transition:all 0.2s var(--fc2-ease-standard); cursor:pointer; } .fc2-enh-tab-btn:hover { background:rgba(255,255,255,0.05); color:var(--fc2-text); } .fc2-enh-tab-btn.active { background:rgba(var(--fc2-primary-rgb),0.1); color:var(--fc2-primary); font-weight:600; } .fc2-enh-tab-btn .fc2-icon { font-size:1.1em; opacity:0.8; } .fc2-enh-tab-btn.active .fc2-icon { opacity:1; } .fc2-enh-tab-btn:active { transform:scale(0.96); } /* Tab Content Transitions */ .fc2-tab-content-wrapper { height:100%; overflow-y:auto; padding:1.5rem 2rem; scrollbar-width:thin; transition: opacity 0.35s var(--fc2-ease-out), transform 0.4s var(--fc2-ease-out); opacity: 0; transform: translateY(15px) scale(0.98); will-change: opacity, transform; } .fc2-tab-content-wrapper.fc2-entering { opacity: 1; transform: translateY(0) scale(1); } .fc2-tab-content-wrapper.fc2-leaving { opacity: 0; transform: translateY(-15px) scale(1.02); pointer-events: none; } /* Mobile Bottom Navigation */ @media(max-width:768px) { .fc2-enh-settings-tabs { order:2; width:100%; height:auto; flex-direction:row; border-right:none; border-top:1px solid rgba(255,255,255,0.1); background:rgba(0,0,0,0.2); padding:0; overflow-x:auto; padding-bottom:env(safe-area-inset-bottom); } .fc2-enh-tab-btn { flex-direction:column; gap:4px; padding:8px 2px; font-size:10px; justify-content:center; border-radius:0; flex:1; min-width:60px; } .fc2-enh-tab-btn .fc2-icon { font-size:1.4em; margin-bottom:2px; } } `; const dashboard = (_C) => ` /* ============================================================ DASHBOARD (MANAGEMENT CENTER HERO) ============================================================ */ .fc2-dashboard-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:1.5rem; width:100%; } .fc2-dashboard-card { display:flex; flex-direction:column; padding:1.5rem; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.05); border-radius:var(--fc2-radius-lg); transition:all 0.3s var(--fc2-ease-out); position:relative; overflow:hidden; } .fc2-dashboard-card:hover { background:rgba(255,255,255,0.04); border-color:rgba(255,255,255,0.1); transform:translateY(-2px); } .fc2-dashboard-card h4 { margin:0 0 1.25rem 0; font-size:0.95rem; font-weight:600; color:var(--fc2-text-dim); display:flex; align-items:center; gap:10px; } .fc2-dashboard-card .fc2-icon { color:var(--fc2-primary); font-size:1.2em; } .fc2-stat-value { font-family:var(--fc2-font-mono); font-size:2.5rem; font-weight:700; color:var(--fc2-text); line-height:1; margin-bottom:0.5rem; } .fc2-stat-label { font-size:0.85rem; color:var(--fc2-text-dim); opacity:0.7; } /* LED Status Indicators */ .fc2-led-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:10px; background:var(--fc2-text-muted); position:relative; } .fc2-led-group { display: flex; align-items: center; margin-bottom: 8px; } .fc2-led-dot.active { background:var(--fc2-primary); box-shadow:0 0 10px rgba(var(--fc2-primary-rgb),0.5); } .fc2-led-dot.active::after { content:''; position:absolute; inset:-2px; border-radius:50%; border:1px solid var(--fc2-primary); animation:fc2-led-pulse 2s infinite; } @keyframes fc2-led-pulse { 0% { transform:scale(1); opacity:0.8; } 100% { transform:scale(2.5); opacity:0; } } .fc2-card-actions { display:flex; gap:8px; margin-top:auto; padding-top:12px; } .fc2-card-subtitle { font-size:12px!important; font-weight:400!important; color:var(--fc2-text-muted); margin-left:auto; } .fc2-stat-unit { font-size:0.5em; margin-left:4px; opacity:0.6; } `; const collection = (C) => ` /* ============================================================ COLLECTION GRID (FLUID RESIZE) ============================================================ */ .fc2-collection-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--fc2-coll-width,200px),1fr)); gap:1.5rem; width:100%; margin-top:10px; transition:gap 0.2s; } .fc2-collection-container { display:flex; flex-direction:column; gap:10px; width:100%; } @media(max-width:768px) { .fc2-collection-grid { gap:0.75rem; grid-template-columns:repeat(auto-fill,minmax(140px,1fr))!important; } } .fc2-collection-toolbar { display:flex!important; flex-wrap:wrap!important; gap:0.75rem!important; align-items:center!important; padding:0.75rem 1rem!important; overflow-x:visible!important; } .fc2-collection-toolbar .filter-group { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; } .fc2-collection-toolbar .filter-group.primary { flex:1 1 200px; } .fc2-collection-toolbar .filter-group:first-child { flex-shrink:1; min-width:120px; } .fc2-collection-toolbar.stats-display { font-size:11px; opacity:0.7; margin-left:4px; white-space:nowrap; } .fc2-card-remove-overlay { position:absolute; top:8px; right:8px; width:24px; height:24px; background:rgba(239,68,68,0.8); color:white; border-radius:50%; display:flex; align-items:center; justify-content:center; cursor:pointer; opacity:0; transition:all 0.2s ease; z-index:10; font-size:18px; backdrop-filter:blur(4px); } .${C.cardRebuilt}:hover .fc2-card-remove-overlay { opacity:1; } .fc2-card-remove-overlay:hover { background:#ef4444; transform:scale(1.1); } .fc2-batch-action-bar { animation:fc2-fade-in-scale 0.3s ease-out; backdrop-filter:blur(8px); } .${C.cardRebuilt}.is-selected { border-color:var(--fc2-primary)!important; box-shadow:0 0 15px rgba(var(--fc2-primary-rgb),0.4)!important; } .${C.cardRebuilt} .resource-btn:not(.${C.fc2IdBadge}) .${C.buttonText}, .enh-toolbar .resource-btn:not(.${C.fc2IdBadge}) .${C.buttonText} { display:none!important; } .toolbar-group { display:flex; align-items:center; gap:8px; } .toolbar-group.search { flex:1 1 200px; min-width:0; } .toolbar-group.filters { display:flex; gap:8px; flex-shrink:0; } .toolbar-group.actions { display:flex; gap:8px; flex-shrink:0; margin-left:auto; } .input-wrapper { position:relative; display:flex; align-items:center; flex:1; min-width:0; } .input-wrapper .fc2-enh-input { width:100%; padding-right:28px; } .fc2-search-clear { position:absolute; right:8px; top:50%; transform:translateY(-50%); width:20px; height:20px; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,0.1); color:var(--fc2-text-dim); border:none; border-radius:50%; font-size:14px!important; cursor:pointer; transition:all 0.15s; padding:0; line-height:1; } .fc2-search-clear:hover { background:rgba(255,255,255,0.2); color:var(--fc2-text); } .fc2-back-to-top { position:fixed; bottom:24px; right:24px; width:40px; height:40px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.6); color:var(--fc2-text); border:1px solid var(--fc2-border); border-radius:50%; cursor:pointer; z-index:var(--fc2-z-dropdown); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); transition:all 0.2s var(--fc2-ease-standard); box-shadow:var(--fc2-shadow-md); } .fc2-back-to-top:hover { background:rgba(255,255,255,0.15); transform:translateY(-2px); } .fc2-back-to-top .fc2-icon { font-size:1.2em; } .fc2-no-results { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:4rem 2rem; color:var(--fc2-text-muted); grid-column:1/-1; } .fc2-no-results .icon { font-size:2rem; opacity:0.4; } .fc2-no-results .icon svg { width:32px; height:32px; fill:currentColor; } .fc2-no-results .text { font-size:14px!important; } .fc2-health-progress { display:flex; align-items:center; gap:6px; padding:4px 10px; background:rgba(var(--fc2-primary-rgb),0.08); border:1px solid rgba(var(--fc2-primary-rgb),0.15); border-radius:var(--fc2-radius-sm); font-size:12px!important; color:var(--fc2-primary); white-space:nowrap; animation:fc2-pulse 2s infinite; } .fc2-health-progress .fc2-icon { font-size:0.9em; } .fc2-gallery-sentinel { height:1px; width:100%; grid-column:1/-1; } .fc2-batch-action-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 16px; background:rgba(var(--fc2-primary-rgb),0.05); border:1px solid rgba(var(--fc2-primary-rgb),0.15); border-radius:var(--fc2-radius-md); animation:fc2-fade-in 0.3s var(--fc2-ease-out); backdrop-filter:blur(8px); } .fc2-batch-action-bar .batch-info { display:flex; align-items:center; gap:8px; } .fc2-batch-action-bar .batch-info .count { font-weight:600!important; color:var(--fc2-primary); } .fc2-batch-action-bar .batch-actions { display:flex; gap:8px; } .fc2-label-dim { font-size:12px!important; color:var(--fc2-text-muted); } @media(max-width:768px) { .fc2-collection-toolbar { flex-direction:column; align-items:stretch!important; } .toolbar-group { flex-wrap:wrap; } .toolbar-group.actions { margin-left:0; } .fc2-back-to-top { bottom:80px; right:16px; } .fc2-batch-action-bar { flex-direction:column; gap:8px; } } `; const settings = (_C) => ` /* ============================================================ SETTINGS GROUPS & PORTALS ============================================================ */ .fc2-enh-settings-group { display:flex; flex-direction:column; gap:12px; padding:1.25rem 1.5rem; background:rgba(255,255,255,0.015); border:1px solid rgba(255,255,255,0.03); border-radius:var(--fc2-radius-md); transition:all 0.2s var(--fc2-ease-standard); height:100%; position:relative; will-change: background-color, border-color; } .fc2-enh-settings-group:hover { background:rgba(255,255,255,0.04); border-color:rgba(255,255,255,0.15); } .fc2-enh-settings-group h3 { margin-top:0; margin-bottom:1rem; padding-bottom:0.75rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; opacity:0.9; } .portal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:0.6rem 1.25rem; margin-top:0.5rem; } .portal-item { display:flex; align-items:center; gap:10px; padding:0.75rem 1rem; border:1px solid var(--fc2-border); border-radius:var(--fc2-btn-radius); cursor:pointer; transition:all 0.2s var(--fc2-ease-out); background: rgba(255, 255, 255, 0.02); will-change: transform, background-color, border-color, box-shadow; } .portal-item span { color: var(--fc2-text) !important; font-weight: 600 !important; font-size: 14px !important; opacity: 0.9; } .portal-item:hover { background:rgba(255,255,255,0.06); border-color:rgba(255, 255, 255, 0.2); } .portal-item.active { background: rgba(255, 255, 255, 0.08); border-color: var(--fc2-primary); box-shadow: 0 0 15px rgba(255, 255, 255, 0.05); } .portal-item.active span { opacity: 1; color: var(--fc2-text) !important; } .fc2-filter-chip { padding:4px 12px; background:rgba(255,255,255,0.05); border:1px solid var(--fc2-border); border-radius:var(--fc2-radius-lg); font-size:12px; cursor:pointer; transition:all 0.2s ease; color:var(--fc2-text-dim); } .fc2-filter-chip.active { background:var(--fc2-primary); border-color:var(--fc2-primary); color:var(--fc2-on-primary); box-shadow:0 0 10px rgba(var(--fc2-primary-rgb),0.3); } .fc2-settings-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:1.5rem; } .fc2-settings-card-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:10px 16px; } .fc2-grid-actions { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; } .fc2-auth-section { margin-top:12px; padding-top:12px; border-top:1px solid var(--fc2-border); } .fc2-auth-section .dim { font-size:12px!important; color:var(--fc2-text-muted); } .fc2-portal-actions { display:flex; gap:8px; margin-bottom:16px; } /* ============================================================ DEBUG TAB ============================================================ */ .fc2-debug-container { display:flex; flex-direction:column; gap:12px; height:100%; } .fc2-debug-header { display:flex; flex-direction:column; gap:10px; flex-shrink:0; } .fc2-debug-actions { display:flex; gap:8px; flex-wrap:wrap; } .fc2-debug-filters { display:flex; align-items:center; gap:12px; flex-wrap:wrap; } .fc2-log-list-container { flex:1; overflow-y:auto; min-height:0; border:1px solid var(--fc2-border); border-radius:var(--fc2-radius-sm); background:rgba(0,0,0,0.2); padding:8px; scrollbar-width:thin; } .fc2-log-item { display:flex; align-items:flex-start; gap:8px; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.03); font-family:var(--fc2-font-mono)!important; font-size:12px!important; line-height:1.6!important; transition:background 0.15s; } .fc2-log-item:hover { background:rgba(255,255,255,0.03); } .fc2-log-item:last-child { border-bottom:none; } .fc2-log-item.level-error { border-left:3px solid var(--fc2-danger); } .fc2-log-item.level-warn { border-left:3px solid var(--fc2-warn); } .fc2-log-item.level-info { border-left:3px solid var(--fc2-text-dim); } .fc2-log-item.level-success { border-left:3px solid var(--fc2-success); } .fc2-log-item.level-debug { border-left:3px solid var(--fc2-text-muted); } .fc2-log-time { color:var(--fc2-text-muted); white-space:nowrap; flex-shrink:0; } .fc2-log-level { font-weight:600!important; white-space:nowrap; flex-shrink:0; min-width:56px; } .fc2-log-module { color:var(--fc2-text-dim); white-space:nowrap; flex-shrink:0; } .fc2-log-msg { color:var(--fc2-text); word-break:break-word; flex:1; } .level-error .fc2-log-level { color:var(--fc2-danger); } .level-warn .fc2-log-level { color:var(--fc2-warn); } .level-info .fc2-log-level { color:var(--fc2-text-dim); } .level-success .fc2-log-level { color:var(--fc2-success); } .level-debug .fc2-log-level { color:var(--fc2-text-muted); } .fc2-log-payload { margin:6px 0 0 0; padding:8px; background:rgba(0,0,0,0.3); border-radius:var(--fc2-radius-sm); font-size:11px!important; color:var(--fc2-text-dim); overflow-x:auto; white-space:pre-wrap; word-break:break-all; } .fc2-log-payload-toggle { padding:2px 8px; background:rgba(255,255,255,0.05); border:1px solid var(--fc2-border); border-radius:var(--fc2-radius-sm); color:var(--fc2-text-dim); font-size:11px!important; cursor:pointer; margin-left:auto; flex-shrink:0; transition:all 0.15s; } .fc2-log-payload-toggle:hover { background:rgba(255,255,255,0.1); color:var(--fc2-text); } /* ============================================================ ABOUT TAB ============================================================ */ .fc2-about-tab { display:flex; flex-direction:column; gap:1.5rem; } .fc2-about-header { text-align:center; padding:2rem 0 1rem; } .fc2-version-badge { display:inline-block; margin-top:8px; padding:4px 12px; background:rgba(var(--fc2-primary-rgb),0.1); border:1px solid rgba(var(--fc2-primary-rgb),0.2); border-radius:var(--fc2-radius-full); font-size:12px!important; font-weight:600!important; color:var(--fc2-primary); letter-spacing:0.05em; } .fc2-about-desc { margin-top:12px; color:var(--fc2-text-dim); font-size:14px!important; line-height:1.6!important; max-width:500px; margin-left:auto; margin-right:auto; } .fc2-about-content { font-size:13px!important; line-height:1.7!important; color:var(--fc2-text-dim); } .fc2-about-content.dmca { color:var(--fc2-text-muted); } .fc2-about-footer { text-align:center; padding:1.5rem 0; font-size:13px!important; color:var(--fc2-text-muted); } .fc2-link { color:var(--fc2-primary); text-decoration:none; transition:opacity 0.2s; } .fc2-link:hover { opacity:0.7; } .fc2-dashboard-card.warning { border-color:rgba(250,179,135,0.2); } .fc2-dashboard-card.warning h4 .fc2-icon { color:var(--fc2-warn); } .fc2-dashboard-card.full-width { grid-column:1/-1; } @media(max-width:768px) { .fc2-settings-grid { grid-template-columns:1fr; } .fc2-settings-card-grid { grid-template-columns:1fr; } .fc2-grid-actions { flex-direction:column; } .fc2-debug-filters { gap:8px; } .fc2-log-item { flex-wrap:wrap; gap:4px; } .fc2-log-time, .fc2-log-module { display:none; } } `; const modal = (_C) => ` /* ============================================================ MODAL & SETTINGS PANEL ============================================================ */ .enh-modal-backdrop { position:fixed; inset:0; z-index:var(--fc2-z-overlay); background:rgba(0,0,0,0.4); transition:all 0.3s var(--fc2-ease-standard); pointer-events: auto; } .enh-modal-panel { position:fixed; top:50%; left:50%; z-index:var(--fc2-z-modal); background-color:var(--fc2-liquid-bg); color:var(--fc2-text); border:1px solid transparent; background-clip:padding-box,border-box; background-origin:padding-box,border-box; background-image: linear-gradient(to bottom,transparent,transparent), var(--fc2-liquid-iridescent); border-radius:var(--fc2-radius-lg); box-shadow: var(--fc2-rim-light), 0 40px 100px -20px rgba(0,0,0,0.8); overflow:hidden; animation:fc2-pop-in 0.4s var(--fc2-ease-out); transform:translate(-50%,-50%); will-change:transform; pointer-events: auto; } .fc2-enh-settings-panel { width:100vw; height:100dvh; display:flex; flex-direction:column; overflow:hidden; position:fixed; top:0; left:0; z-index:var(--fc2-z-modal) !important; transform:none !important; background:var(--fc2-liquid-bg); backdrop-filter:none; -webkit-backdrop-filter:none; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); pointer-events: auto; } .fc2-enh-settings-header { display:flex; align-items:center; justify-content:space-between; padding:0.85rem 1.75rem; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(255,255,255,0.05); flex-shrink:0; z-index:10; } .fc2-enh-settings-body { display:flex; flex:1; min-height:0; overflow:hidden; } .fc2-enh-settings-content { position:relative; flex:1; min-height:0; padding:0; overflow:hidden; background:transparent; } .fc2-enh-settings-footer { display:flex; justify-content:flex-end; gap:1rem; padding:1.25rem 2rem; background:rgba(0,0,0,0.15); border-top:1px solid var(--fc2-border); flex-shrink:0; } .close-btn { display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; padding:0; background:transparent; color:var(--fc2-text-dim); border:1px solid transparent; border-radius:var(--fc2-radius-sm); cursor:pointer; transition:all 0.2s var(--fc2-ease-standard); } .close-btn:hover { background:rgba(255,255,255,0.08); color:var(--fc2-text); border-color:rgba(255,255,255,0.1); } .fc2-loading-overlay { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; height:100%; min-height:200px; color:var(--fc2-text-dim); } .fc2-loading-spinner { width:32px; height:32px; border:2px solid rgba(255,255,255,0.1); border-top-color:var(--fc2-primary); border-radius:50%; animation:fc2-spin 0.8s linear infinite; } .fc2-loading-text { font-size:13px!important; opacity:0.6; } .fc2-error-state { display:flex; align-items:center; justify-content:center; height:100%; min-height:200px; color:var(--fc2-danger); font-size:14px!important; } @media(max-width:768px) { .fc2-enh-settings-body { flex-direction:column; } .fc2-enh-settings-content { order:1; padding:1rem; } .fc2-enh-settings-header { padding:0.75rem 1rem; } .fc2-enh-settings-footer { padding:0.75rem 1rem; padding-bottom:max(0.75rem,env(safe-area-inset-bottom)); } } `; const misc = (C) => ` /* ============================================================ PLAYER TOOLBAR (DESKTOP) ============================================================ */ .enh-toolbar { width: 100%; margin: var(--fc2-space-md) 0; background: var(--fc2-surface); border: 1px solid var(--fc2-border); border-radius: var(--fc2-radius-lg); padding: var(--fc2-space-sm) var(--fc2-space-md); box-shadow: var(--fc2-shadow-sm); backdrop-filter: blur(var(--fc2-blur)); -webkit-backdrop-filter: blur(var(--fc2-blur)); box-sizing: border-box; } .enh-toolbar .info-area { display: grid !important; grid-template-columns: 1fr fit-content(50%) 1fr !important; align-items: center !important; width: 100% !important; gap: var(--fc2-space-sm) !important; } .enh-toolbar .resource-btn { flex-shrink: 0 !important; width: auto !important; } .enh-toolbar .card-top-right-controls { position: static !important; display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; gap: var(--fc2-space-sm) !important; align-items: center !important; justify-content: flex-start !important; min-width: 0 !important; overflow: hidden !important; } .enh-toolbar .btn-actress { margin: 0 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; min-width: 0 !important; max-width: 100% !important; text-align: center !important; padding: 0 var(--fc2-space-md) !important; } .enh-toolbar .resource-links-container { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; gap: var(--fc2-space-sm) !important; align-items: center !important; justify-content: flex-end !important; min-width: 0 !important; overflow: hidden !important; } /* ============================================================ DROPDOWN & TOOLTIP ============================================================ */ .enh-dropdown { position:relative; display:inline-flex; } .enh-dropdown-content { position:absolute; top:calc(100% + var(--fc2-space-sm)); right:0; z-index:var(--fc2-z-dropdown); display:none; flex-direction:column; gap:var(--fc2-space-xs); min-width:140px; padding:var(--fc2-space-sm); background:rgba(0,0,0,0.6); border:1px solid rgba(255,255,255,0.12); border-radius:var(--fc2-radius-md); box-shadow:var(--fc2-shadow-lg); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); animation:fc2-dropdown-in 0.2s var(--fc2-ease-standard); } .enh-dropdown.active .enh-dropdown-content { display:flex; } .tooltip { display:none; position:absolute; bottom:100%; left:50%; transform:translateX(-50%) translateY(-8px); padding:5px 8px; background:rgba(0,0,0,0.9); color:#fff; border-radius:4px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity 0.2s; z-index:var(--fc2-z-tooltip); } .${C.resourceBtn}:hover .tooltip { display:block; opacity:1; } /* ============================================================ SKELETON & LOADING ============================================================ */ .fc2-skeleton { position:relative; overflow:hidden!important; background:var(--fc2-surface-low); border-radius:var(--fc2-radius-sm); } .fc2-skeleton::after { content:''; position:absolute; inset:0; background:linear-gradient(90deg, transparent, rgba(255,255,255,0.03) 20%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 80%, transparent); background-size:200% 100%; animation:fc2-shimmer 2.5s infinite linear; } /* ============================================================ GALLERY & VIEWER ============================================================ */ .enh-viewer-backdrop { position:fixed; inset:0; z-index:var(--fc2-z-gallery); display:flex; flex-direction:column; background:var(--fc2-surface-float); backdrop-filter:blur(24px) saturate(180%); -webkit-backdrop-filter:blur(24px) saturate(180%); pointer-events: auto; } .enh-viewer-stage { position:relative; display:flex; flex:1; align-items:center; justify-content:center; overflow:hidden; padding:var(--fc2-space-md); width: 100%; } .enh-viewer-stage img, .enh-viewer-stage video { max-width:100%; max-height:100%; object-fit:contain; border-radius:var(--fc2-radius-lg); box-shadow:0 20px 60px rgba(0,0,0,0.8); transition:transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); will-change:transform; } @keyframes fc2-slide-in-right { from { transform:translateX(50px); opacity:0; } to { transform:translateX(0); opacity:1; } } @keyframes fc2-slide-in-left { from { transform:translateX(-50px); opacity:0; } to { transform:translateX(0); opacity:1; } } @keyframes fc2-fade-in { from { opacity:0; transform:scale(0.98); } to { opacity:1; transform:scale(1); } } .slide-next img, .slide-next video { animation: fc2-slide-in-right 0.3s var(--fc2-ease-out); } .slide-prev img, .slide-prev video { animation: fc2-slide-in-left 0.3s var(--fc2-ease-out); } .slide-init img, .slide-init video { animation: fc2-fade-in 0.3s var(--fc2-ease-out); } .enh-viewer-nav { position:absolute; top:50%; transform:translateY(-50%); z-index:var(--fc2-z-toast); width:48px; height:48px; display:flex; align-items:center; justify-content:center; background:rgba(255,255,255,0.1); border-radius:50%; cursor:pointer; transition:all 0.2s; /* backdrop-filter:blur(4px); Removed for performance */ color: white; } .enh-viewer-nav:hover { background:rgba(255,255,255,0.2); transform:translateY(-50%) scale(1.1); } .enh-viewer-nav.prev { left:20px; } .enh-viewer-nav.next { right:20px; } .enh-viewer-close { position:absolute; top:20px; right:20px; z-index:var(--fc2-z-toast); width:40px; height:40px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2); border-radius:50%; cursor:pointer; color:white; transition:background 0.2s; } .enh-viewer-close:hover { background:rgba(0,0,0,0.5); } .enh-viewer-actions { position:absolute; bottom:100px; right:20px; z-index:var(--fc2-z-toast); display:flex; flex-direction:column; gap:10px; } .enh-viewer-action { width:40px; height:40px; border-radius:50%; background:rgba(0,0,0,0.4); border:1px solid rgba(255,255,255,0.1); color:white; display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all 0.2s; } .enh-viewer-action:hover { background:var(--fc2-primary); color:var(--fc2-on-primary); } .enh-viewer-counter { position:absolute; top:20px; left:20px; z-index:var(--fc2-z-toast); background:rgba(0,0,0,0.5); padding:4px 12px; border-radius:var(--fc2-radius-xl); color:white; font-size:14px; /* backdrop-filter:blur(4px); Removed for performance */ } .enh-viewer-thumbs { height:80px; width:100%; display:flex; gap:10px; padding:10px 20px; overflow-x:auto; background:rgba(0,0,0,0.3); /* backdrop-filter:blur(10px); Removed for performance */ z-index:var(--fc2-z-toast); } .enh-thumb-item { height:100%; aspect-ratio:16/9; border-radius:6px; overflow:hidden; cursor:pointer; opacity:0.5; transition:opacity 0.2s; border:2px solid transparent; flex-shrink:0; } .enh-thumb-item.active { opacity:1; border-color:var(--fc2-primary); } .enh-thumb-item img, .enh-thumb-item video { width:100%; height:100%; object-fit:cover; } `; const actionsheet = (_C) => ` /* ============================================================ ACTION SHEET (MOBILE) ============================================================ */ .fc2-action-sheet-backdrop { position:fixed; inset:0; z-index:var(--fc2-z-actionsheet); opacity:0; will-change: opacity, visibility; visibility:hidden; transition:all 0.3s var(--fc2-ease-standard); pointer-events: auto; } .fc2-action-sheet-backdrop.active { opacity:1; visibility:visible; } .fc2-action-sheet { position:fixed; left:0; right:0; bottom:0; background:var(--fc2-surface-float); border-top-left-radius:var(--fc2-radius-xl); will-change: transform; border-top-right-radius:var(--fc2-radius-xl); z-index:calc(var(--fc2-z-actionsheet) + 1); transform:translateY(100%); transition:transform 0.4s var(--fc2-ease-out); padding-bottom:calc(var(--fc2-space-lg) + env(safe-area-inset-bottom,0px)); border:1px solid rgba(255,255,255,0.1); font-family:var(--fc2-font); pointer-events: auto; } .fc2-action-sheet.desktop { top:50%; bottom:auto; left:50%; right:auto; width:400px; max-width:90vw; border-radius:var(--fc2-radius-xl); transform:translate(-50%,-50%) scale(0.9); box-shadow:0 32px 64px rgba(0,0,0,0.6); opacity:0; visibility:hidden; pointer-events:none; transition:all 0.3s var(--fc2-ease-out); } .fc2-action-sheet.active { transform:translateY(0); } .fc2-action-sheet.desktop.active { transform:translate(-50%,-50%) scale(1); opacity:1; visibility:visible; pointer-events:auto; } .fc2-action-sheet-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid rgba(255,255,255,0.1); } .fc2-action-sheet-title { font-size:16px!important; font-weight:600!important; color:var(--fc2-text); } .fc2-action-sheet-close-btn { background:transparent; border:none; color:var(--fc2-text-dim); font-size:20px!important; cursor:pointer; padding:4px; line-height:1; transition:color 0.2s; } .fc2-action-sheet-close-btn:hover { color:var(--fc2-text); } .fc2-action-sheet-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(100px,1fr)); gap:12px; padding:20px; max-height:60vh; overflow-y:auto; } .fc2-action-sheet-item { display:flex; flex-direction:column; align-items:center; gap:8px; padding:12px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.05); border-radius:var(--fc2-radius-md); text-decoration:none!important; color:var(--fc2-text-dim)!important; transition:all 0.2s; text-align:center; } .fc2-action-sheet-item:hover { background:rgba(var(--fc2-primary-rgb),0.1); border-color:var(--fc2-primary); color:var(--fc2-primary)!important; transform:translateY(-2px); } .fc2-action-sheet-item .fc2-icon { font-size:24px; margin-bottom:4px; } .fc2-action-sheet-item span:last-child { font-size:12px!important; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:100%; } `; const getComponentStyles = (C) => ` ${tokens} ${base()} ${toast()} ${fab()} ${card(C)} ${button(C)} ${form()} ${tabs()} ${dashboard()} ${collection(C)} ${settings()} ${modal()} ${misc(C)} ${actionsheet()} `; const getMobileStyles = (C) => ` /* ============================================================ MOBILE TOUCH OPTIMIZATIONS ============================================================ */ @media (max-width: 768px) { * { /* Prevent double-tap zoom on mobile */ touch-action: manipulation; } body { /* Smooth scrolling on mobile */ -webkit-overflow-scrolling: touch; } html { /* Prevent horizontal scroll if possible, but don't force width */ overflow-x: hidden !important; } /* Hardware Acceleration for Mobile */ .${C.processedCard}, .${C.cardRebuilt}, .${C.resourceBtn}, .fc2-fab-btn, .fc2-fab-trigger { transform: translateZ(0); -webkit-transform: translateZ(0); will-change: transform; } /* Disable hover effects on touch devices to prevent "sticky" hover */ @media (hover: none) { .${C.resourceBtn}:hover, .card-top-right-controls > *:hover, .fc2-fab-btn:hover, .fc2-fab-trigger:hover, .${C.processedCard}:hover { transform: none !important; box-shadow: none !important; border-color: rgba(255, 255, 255, 0.1) !important; } } /* Sharper animations for mobile performance */ * { animation-duration: 0.2s !important; transition-duration: 0.2s !important; } /* ============================================================ SETTINGS PANEL MOBILE ============================================================ */ .fc2-enh-settings-panel { display: flex !important; flex-direction: column !important; } .fc2-enh-settings-body { flex-direction: column !important; } .fc2-enh-settings-content { padding: 1rem !important; } .fc2-enh-tab-btn { display: flex; flex: 1; width: auto !important; justify-content: center; padding: 0 0.5rem !important; height: 36px !important; font-size: 0.85rem !important; border-radius: 8px !important; } .fc2-enh-form-row { flex-direction: column !important; align-items: flex-start !important; gap: 0.5rem !important; padding: 0.75rem 0 !important; border-bottom: 1px solid rgba(255,255,255,0.05) !important; } /* Prevent checkboxes from splitting on mobile */ .fc2-enh-form-row.checkbox { flex-direction: row !important; align-items: center !important; padding: 0.75rem 0.5rem !important; } .fc2-enh-settings-group { margin-bottom: 1.5rem !important; padding: 0 !important; } .portal-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 8px !important; } .portal-item { padding: 8px !important; font-size: 13px !important; } .data-management-actions { flex-direction: row !important; flex-wrap: wrap !important; gap: 8px !important; } .data-management-actions > * { flex: 1 1 calc(50% - 8px) !important; min-width: 120px !important; height: 44px !important; } /* ============================================================ GRID & CARD LAYOUT MOBILE ============================================================ */ /* Force single column layout on common containers - tightening to avoid site collisions */ div.grid, .post-list, .posts:not(.video-player):not(.video-container), div.flex-wrap:not(.entry-content), .movie-list, .work-list, .artist-list, #artist-list, #work-list, .tile-images { display: grid !important; grid-template-columns: 1fr !important; gap: 12px !important; width: 100% !important; max-width: 100% !important; padding: 10px !important; box-sizing: border-box !important; margin: 0 !important; } .container, .main-content, #main, #content { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; } .${C.cardRebuilt} { width: 100% !important; max-width: 100% !important; margin: 0 !important; overflow: hidden !important; flex-basis: 100% !important; /* For flex containers */ } /* Larger Touch Targets for Mobile */ .card-top-right-controls { top: 12px !important; right: 12px !important; gap: 10px !important; } .card-top-right-controls > * { min-height: 44px; min-width: 44px; height: 36px !important; padding: 0 12px !important; font-size: 14px !important; } .${C.resourceBtn} { min-height: 44px; height: 44px !important; padding: 0 16px !important; font-size: 14px !important; } .btn-actress { width: 90% !important; margin: 8px auto !important; padding: 8px 16px !important; font-size: 15px !important; } .fc2-fab-trigger { width: 56px !important; height: 56px !important; font-size: 24px !important; } .fc2-fab-btn { width: 48px !important; height: 48px !important; font-size: 20px !important; } /* ============================================================ TOOLBAR & MODAL MOBILE ============================================================ */ .enh-toolbar { display: flex !important; height: auto !important; min-height: 60px !important; margin: 10px 0 !important; border-radius: var(--fc2-radius-md) !important; overflow-x: auto !important; } .enh-toolbar .info-area { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; gap: 12px !important; width: auto !important; min-width: 100% !important; height: auto !important; padding: 12px !important; align-items: center !important; } .enh-toolbar .card-top-right-controls, .enh-toolbar .btn-actress, .enh-toolbar .resource-links-container { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; justify-content: center !important; align-items: center !important; width: auto !important; margin: 0 !important; flex-shrink: 0 !important; } .enh-toolbar .resource-links-container { flex-wrap: nowrap !important; gap: 10px !important; } .enh-toolbar .resource-links-container .${C.resourceBtn} { flex: 1 !important; width: auto !important; min-width: 80px !important; } .enh-dropdown-content { min-width: 160px !important; max-width: 90vw !important; } .enh-dropdown-content .${C.resourceBtn} { height: 44px !important; font-size: 14px !important; } .enh-viewer-nav { display: none !important; /* Hide arrows on mobile, rely on swipe */ } .enh-viewer-thumbs { bottom: 20px !important; width: 95% !important; height: 50px !important; gap: 6px !important; } .enh-thumb-item { width: 44px !important; height: 44px !important; } .enh-viewer-actions { top: 10px !important; padding: 4px !important; } .enh-viewer-action { width: 44px !important; height: 44px !important; } .enh-viewer-close { top: 10px !important; right: 10px !important; width: 48px !important; height: 48px !important; background: rgba(0,0,0,0.5) !important; border-radius: 50% !important; backdrop-filter: blur(8px) !important; } .enh-modal-panel { width: 95% !important; max-width: 95% !important; max-height: 90vh !important; } /* Improved Grid Responsiveness */ .preview-hero-section { margin-bottom: 15px !important; } .preview-hero-card { border-radius: var(--fc2-radius-md) !important; overflow: hidden !important; } .${C.extraPreviewGrid} { grid-template-columns: repeat(2, 1fr) !important; gap: 8px !important; } .preview-item { height: 120px !important; border-radius: 8px !important; } /* FAB Container - safely above bottom bar by default, but allow JS to override */ .fc2-fab-container { bottom: 80px; right: 20px; } /* Toasts mobile positioning with safe area */ .fc2-toast-container { top: calc(10px + env(safe-area-inset-top, 0px)) !important; right: 10px !important; left: 10px !important; /* Full width on mobile looks better */ } .fc2-toast-item { min-width: 0 !important; width: 100% !important; } .fc2-enh-settings-tabs { scroll-snap-type: x mandatory; scrollbar-width: none; } .fc2-enh-tab-btn { scroll-snap-align: start; } /* Better touch feedback */ .fc2-fab-trigger, .fc2-fab-btn, .enh-viewer-action, .enh-thumb-item { -webkit-tap-highlight-color: rgba(var(--fc2-primary-rgb), 0.2) !important; } } `; const getConsolidatedCss = () => { const C = Config.CLASSES; const performanceFix = location.hostname.includes("missav") || location.hostname.includes("supjav") || location.hostname.includes("javdb") ? ` .${C.processedCard}:nth-child(n+51) { content-visibility: auto; contain-intrinsic-size: 320px 280px; } ` : ""; const siteSpecificFix = location.hostname.includes("fd2ppv") ? ` .artist-card.card-rebuilt, .work-card.card-rebuilt, .work-list > div, .artist-list > div { overflow: visible !important; } ` : ""; return ` ${tokens} ${animations} ${getBaseStyles(C)} ${getComponentStyles(C)} ${performanceFix} ${siteSpecificFix} ${getMobileStyles(C)} `; }; const log$y = Logger.scope("Style"); class StyleService { constructor() { this.themeObserver = null; } async onInit() { log$y.debug("Injecting global styles"); this.injectCss(); this.initThemeDetection(); } onCleanup() { this.themeObserver?.disconnect(); this.themeObserver = null; } injectCss() { const css = getConsolidatedCss(); if (typeof GM_addStyle !== "undefined") { GM_addStyle(css); } else { const style = document.createElement("style"); style.textContent = css; document.head.appendChild(style); } } initThemeDetection() { let lastUpdate = 0; const detectTheme = () => { const now = Date.now(); if (now - lastUpdate < 500) return; lastUpdate = now; const bodyBg = window.getComputedStyle(document.body).backgroundColor; if (!bodyBg || bodyBg === "rgba(0, 0, 0, 0)" || bodyBg === "transparent") return; const rgb = bodyBg.match(/\d+/g); if (rgb && rgb.length >= 3) { const r = parseInt(rgb[0]), g = parseInt(rgb[1]), b = parseInt(rgb[2]); const isLight = r * 0.299 + g * 0.587 + b * 0.114 > 180; const hasClass = document.documentElement.classList.contains("fc2-light-theme"); if (isLight && !hasClass) { document.documentElement.classList.add("fc2-light-theme"); } else if (!isLight && hasClass) { document.documentElement.classList.remove("fc2-light-theme"); } } }; if (document.body) detectTheme(); else document.addEventListener("DOMContentLoaded", detectTheme); this.themeObserver = new MutationObserver(() => detectTheme()); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ["class", "style"] }); } getCss() { return getConsolidatedCss(); } } const StyleManager = AppContainer.register("style", new StyleService()); const log$x = Logger.scope("UIHost"); class OverlayStackImpl { constructor() { this.stack = []; this.handleKeyDown = (e) => { if (e.key === "Escape") { const top = this.stack[this.stack.length - 1]; if (top) { e.preventDefault(); e.stopPropagation(); top.close(); } } }; } push(overlay) { if (this.stack.length === 0) { document.addEventListener("keydown", this.handleKeyDown, true); } if (!this.stack.includes(overlay)) { this.stack.push(overlay); } } remove(overlay) { const index = this.stack.indexOf(overlay); if (index > -1) { this.stack.splice(index, 1); } if (this.stack.length === 0) { document.removeEventListener("keydown", this.handleKeyDown, true); } } } const OverlayStack = new OverlayStackImpl(); class UIHostImpl { constructor() { this.host = null; this._shadow = null; this.styleSheet = null; } onInit() { if (this.host && this.host.isConnected) return; log$x.debug("Initializing shadow host"); this.host = h("div", { id: DOM_IDS.UI_HOST, style: { position: "fixed", inset: "0", pointerEvents: "none", zIndex: String(UI_CONSTANTS.Z_INDEX_MAX) } }); this._shadow = this.host.attachShadow({ mode: "open" }); const css = StyleManager.getCss(); try { if ("adoptedStyleSheets" in this._shadow) { this.styleSheet = new CSSStyleSheet(); this.styleSheet.replaceSync(css); this._shadow.adoptedStyleSheets = [this.styleSheet]; } else { throw new Error("Not supported"); } } catch { const style = h("style", { innerHTML: css }); this._shadow.appendChild(style); } document.body.appendChild(this.host); } get shadow() { if (!this._shadow) this.onInit(); return this._shadow; } add(el) { if (!this._shadow) this.onInit(); this._shadow.appendChild(el); } } const UIHost = new UIHostImpl(); const log$w = Logger.scope("Toast"); const Toast = { container: null, toasts: new Set(), init() { if (this.container) return; this.container = h("div", { className: "fc2-toast-container" }); UIHost.add(this.container); }, show(message, type = "info", options = {}) { this.init(); const duration = options.duration === 0 ? 0 : options.duration || TIMING.TOAST_DEFAULT_DURATION; const showClose = options.showClose ?? true; const iconSvg = type === "success" ? IconCircleCheck : type === "error" ? IconCircleXmark : type === "warn" ? IconTriangleExclamation : IconCircleInfo; const colorMap = { success: UI_TOKENS.COLORS.SUCCESS, error: UI_TOKENS.COLORS.ERROR, warn: UI_TOKENS.COLORS.WARN, info: UI_TOKENS.COLORS.INFO }; const color = colorMap[type]; const progress = duration > 0 ? h("div", { className: "fc2-toast-progress", style: { background: color, animation: `fc2-toast-shrink ${duration}ms linear forwards` } }) : null; const closeBtn = showClose ? h( "button", { className: "fc2-toast-close", "aria-label": "关闭", onclick: (e) => { e.stopPropagation(); this.remove(el); } }, UIUtils.icon(IconXmark) ) : null; const iconEl = h("div", { className: "fc2-toast-icon" }, UIUtils.icon(iconSvg)); const actionBtn = options.action ? h( "button", { className: "fc2-toast-action", onclick: (e) => { e.stopPropagation(); options.action?.onClick(); this.remove(el); } }, options.action.label ) : null; const el = h( "div", { className: `fc2-toast-item toast-${type}`, onclick: options.onClick }, iconEl, h("span", { className: "fc2-toast-content" }, message), actionBtn, closeBtn, progress ); log$w.trace(`Showing [${type}]: ${message}`); this.container.appendChild(el); this.toasts.add(el); if (duration > 0) { setTimeout(() => this.remove(el), duration); } return el; }, remove(el) { if (!this.toasts.has(el)) return; el.classList.add("hiding"); const onEnd = () => { log$w.trace("Removing toast element"); el.remove(); this.toasts.delete(el); }; el.addEventListener( "animationend", (e) => { if (e.animationName === "fc2-toast-out") onEnd(); }, { once: true } ); setTimeout(onEnd, TIMING.UI_TRANSITION_SLOW); } }; CoreEvents.on(AppEvents.SHOW_TOAST, (payload) => { Toast.show(payload.message, payload.type); }); const log$v = Logger.scope("Retry"); const _RetryManager = class _RetryManager { static async executeWithRetry(operation, config = {}) { const finalConfig = { ...this.defaultConfig, ...config }; let lastError; for (let attempt = 0; attempt <= finalConfig.maxRetries; attempt++) { try { log$v.debug(`Attempt ${attempt + 1}/${finalConfig.maxRetries + 1}`); return await operation(); } catch (error) { lastError = error; if (attempt === finalConfig.maxRetries) { log$v.error("All retries exhausted", error); throw error; } if (finalConfig.shouldRetry && !finalConfig.shouldRetry(error)) { log$v.warn("Error not retryable", error); throw error; } finalConfig.onRetry?.(error, attempt + 1); const delay = finalConfig.backoffMs[attempt] || finalConfig.backoffMs[finalConfig.backoffMs.length - 1]; log$v.debug(`Retry in ${delay}ms`, { attempt: attempt + 1, error }); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; } }; _RetryManager.defaultConfig = { maxRetries: 3, backoffMs: [1e3, 3e3, 5e3], shouldRetry: (error) => { const err = error; const status = err.status; return status === 0 || typeof status === "number" && status >= 500 && status < 600; } }; _RetryManager.configs = { network: { maxRetries: 3, backoffMs: [1e3, 3e3, 5e3], shouldRetry: (error) => { const err = error; return !!(err.status === 0 || err.status === 408 || err.status && err.status >= 500 && err.status < 600); }, onRetry: (_error, attempt) => { Toast.show(`网络错误,正在重试 (${attempt}/3)...`, "warn"); } }, sync: { maxRetries: 2, backoffMs: [2e3, 5e3], shouldRetry: (error) => { const err = error; return !!(err.status !== 401 && err.status !== 403); }, onRetry: (_error, attempt) => { log$v.debug(`Retrying sync (${attempt}/2)`); } }, magnet: { maxRetries: 2, backoffMs: [1500, 3e3], shouldRetry: (error) => { const err = error; return !!(err.status !== 429); } } }; let RetryManager = _RetryManager; const log$u = Logger.scope("Gzip"); class GzipServiceImpl { constructor() { this._isSupported = null; } isSupported() { if (this._isSupported !== null) return this._isSupported; try { this._isSupported = typeof window.CompressionStream === "function" && typeof window.DecompressionStream === "function"; } catch { this._isSupported = false; } return this._isSupported; } async compress(data) { if (!this.isSupported()) { return data instanceof Blob ? data : new Blob([data], { type: "application/json" }); } try { const stream = data instanceof Blob ? data.stream() : new Blob([data], { type: "application/json" }).stream(); const compressedStream = stream.pipeThrough(new CompressionStream("gzip")); return await new Response(compressedStream).blob(); } catch (error) { log$u.error("Compression error", error); return data instanceof Blob ? data : new Blob([data], { type: "application/json" }); } } async decompress(data) { if (!this.isSupported()) { return await data.text(); } try { const isGzip = await this.checkIfGzip(data); if (!isGzip) { return await data.text(); } const stream = data.stream(); const decompressedStream = stream.pipeThrough(new DecompressionStream("gzip")); return await new Response(decompressedStream).text(); } catch (error) { log$u.error("Decompression error", error); return await data.text(); } } async checkIfGzip(blob) { if (blob.size < 2) return false; const arrayBuffer = await blob.slice(0, 2).arrayBuffer(); const header = new Uint8Array(arrayBuffer); return header[0] === 31 && header[1] === 139; } } const GzipService = new GzipServiceImpl(); const log$t = Logger.scope("State"); class StateService { constructor() { this.skipBroadcast = false; this.saveState = Utils.debounce(async (data) => { try { const appState = data; const SENSITIVE_PROPS = ["supabaseKey", "supabasePassword", "webdavPass"]; const SENSITIVE_MAP = { supabaseKey: "SUPABASE_KEY", supabasePassword: "SUPABASE_PASSWORD", webdavPass: "WEBDAV_PASS" }; const { syncStatus: _s, supabaseKey: _sk, supabasePassword: _sp, webdavPass: _wp, ...toSave } = appState; Storage.set(Config.STORAGE_KEYS.SETTINGS, toSave); for (const prop of SENSITIVE_PROPS) { const val = appState[prop]; if (typeof val === "string" && val) { const storageKey = SENSITIVE_MAP[prop]; if (storageKey) await Storage.setEncrypted(STORAGE_KEYS[storageKey], val); } } } catch (e) { log$t.error("Failed to save state", e); } }, TIMING.DEBOUNCE_MS); this.ready = new Promise((resolve) => { this.resolveReady = resolve; }); this.initProxy(); } getDefaults() { return { previewMode: "static", hideNoMagnet: false, hideCensored: false, enableHistory: true, hideViewed: false, enableFollows: false, loadExtraPreviews: false, enableQuickBar: true, showViewedBtn: true, showIdBadge: true, enableMagnets: true, enableExternalLinks: true, enableActressName: true, hideBlocked: true, hideUnwanted: false, language: Storage.get("language", "auto") || "auto", lastSyncTs: UI_CONSTANTS.DEFAULT_TIMESTAMP, supabaseUrl: Storage.get("supabase_url", "") || "", supabaseKey: Storage.get("supabase_key", "") || "", supabaseEmail: Storage.get("supabase_email", "") || "", supabasePassword: Storage.get("supabase_password", "") || "", webdavUrl: Storage.get("webdav_url", "") || "", webdavUser: Storage.get("webdav_user", "") || "", webdavPass: Storage.get("webdav_pass", "") || "", webdavPath: Storage.get("webdav_path", UI_CONSTANTS.DEFAULT_SYNC_FILENAME) || UI_CONSTANTS.DEFAULT_SYNC_FILENAME, syncMode: "none", syncStatus: "idle", syncInterval: 5, replaceFc2Covers: false, enabledPortals: [ "supjav", "missav", "javdb", "javbus", "javlibrary", "dmm", "fc2", "fc2ppvdb", "fd2ppv", "sukebei" ], userGridColumns: Number(Storage.get(STORAGE_KEYS.USER_GRID_COLUMNS, 0)) || 0, debugMode: Storage.get(STORAGE_KEYS.DEBUG_MODE, false) || false, customFolders: Storage.get("custom_folders", []) || [], collectionCardWidth: Number(Storage.get("collection_card_width", 200)) || 200 }; } initProxy() { const defaults = this.getDefaults(); const stored = Storage.get(Config.STORAGE_KEYS.SETTINGS, {}) || {}; if (stored.syncMode === void 0) { const val = Storage.get("sync_mode", "none"); stored.syncMode = ["none", "supabase", "webdav"].includes(val) ? val : "none"; } const rawState = { ...defaults, ...stored }; this.proxy = new Proxy(rawState, { get: (target, prop) => { const value = target[prop]; if (Array.isArray(value)) return [...value]; return value; }, set: (target, prop, value) => { if (typeof prop === "string" && prop in target) { const kProp = prop; const currentValue = target[kProp]; if (currentValue === value) return true; if (typeof currentValue === "object" && currentValue !== null && typeof value === "object" && value !== null) { if (JSON.stringify(currentValue) === JSON.stringify(value)) return true; } target[kProp] = value; if (kProp !== "syncStatus") { this.saveState(target); if (!this.skipBroadcast) { MessagingService.broadcast(MessageType.SETTING_UPDATE, { prop: kProp, value }); } CoreEvents.emit(AppEvents.STATE_CHANGED, { prop: kProp, value }); if (kProp === "language") CoreEvents.emit(AppEvents.LANGUAGE_CHANGED, value); } } else { target[prop] = value; } return true; } }); } async onInit() { log$t.debug("Initializing StateService (secret decryption)"); this.initCrossTabSync(); CoreEvents.on(AppEvents.STATE_CHANGED, ({ prop, value }) => { if (prop === "debugMode") { if (value) Logger.enable(false); else Logger.disable(false); } }); if (this.proxy.debugMode) { Logger.enable(false); } const SENSITIVE_PROPS = ["supabaseKey", "supabasePassword", "webdavPass"]; const SENSITIVE_MAP = { supabaseKey: "SUPABASE_KEY", supabasePassword: "SUPABASE_PASSWORD", webdavPass: "WEBDAV_PASS" }; for (const prop of SENSITIVE_PROPS) { const storageKey = STORAGE_KEYS[SENSITIVE_MAP[prop]]; const raw = Storage.get(storageKey, ""); if (!raw) continue; try { const decrypted = await CryptoService.decrypt(raw); this.proxy[prop] = decrypted; } catch { log$t.warn(`Migrating plain-text secret: ${String(prop)}`); await Storage.setEncrypted(storageKey, raw); this.proxy[prop] = raw; } } this.resolveReady(); log$t.info("StateService ready"); } initCrossTabSync() { MessagingService.onMessage((msg) => { if (msg.type === MessageType.SETTING_UPDATE) { const { prop, value } = msg.payload; if (this.proxy[prop] !== value) { this.skipBroadcast = true; this.proxy[prop] = value; this.skipBroadcast = false; } } }); } on(prop, listener) { if (typeof prop === "function") { const handler = prop; return CoreEvents.on( AppEvents.STATE_CHANGED, handler ); } return CoreEvents.on(AppEvents.STATE_CHANGED, (change) => { if (change.prop === prop && listener) { listener(change.value); } }); } } const State = AppContainer.register("state", new StateService()); class GridService { constructor() { this.styleEl = null; } onBootstrap() { CoreEvents.on(AppEvents.GRID_CHANGED, (cols2) => this.apply(cols2)); CoreEvents.on(AppEvents.STATE_CHANGED, ({ prop, value }) => { if (prop === "userGridColumns") { this.apply(Number(value)); } }); const cols = State.proxy.userGridColumns; if (cols > 0) { this.apply(cols); } } apply(cols) { const hn = location.hostname; if (hn.includes("missav")) { const path = location.pathname; if (/\/(cn\/|en\/|ja\/)?(fc2-ppv-|[a-z]{2,5}-)\d+/i.test(path)) return; } if (!this.styleEl) { this.styleEl = document.createElement("style"); this.styleEl.id = "fc2-modern-grid-style"; document.head.appendChild(this.styleEl); } const sel = hn.includes("fc2ppvdb.com") ? { cont: "[data-enh-grid-container], .flex.flex-wrap.-m-4.py-4, .container > .flex.flex-wrap:not(.flex-end):not(.flex-between), #actress-articles .flex.flex-wrap:not(.flex-end):not(.flex-between), section > div > .flex.flex-wrap:not(.flex-end):not(.flex-between)", card: `> .${Config.CLASSES.cardRebuilt}` } : hn.includes("fd2ppv.cc") ? { cont: "#artist-list, .artist-list, #work-list, .work-list, .flex.flex-wrap:not(.flex-end):not(.flex-between), .container .grid, .other-works-grid", card: `> .${Config.CLASSES.cardRebuilt}` } : hn.includes("supjav.com") ? { cont: ".posts.clearfix:not(:has(.swiper-wrapper))", card: `> .${Config.CLASSES.cardRebuilt}` } : hn.includes("missav") ? { cont: "[data-enh-grid-container]", card: `> .${Config.CLASSES.cardRebuilt}` } : hn.includes("javdb") ? { cont: ".movie-list, .tile-images.tile-small", card: `> .${Config.CLASSES.cardRebuilt}` } : null; if (!sel || cols <= 0) { this.styleEl.textContent = ""; return; } const containerList = sel.cont.split(",").map((s) => s.trim()); const cardRules = containerList.map((s) => `${s} ${sel.card}`).join(", "); const baseGridCss = ` display: grid !important; grid-template-columns: repeat(${Math.min(cols, 2)}, 1fr) !important; gap: 1rem !important; margin: 0 !important; padding: 1rem 10px !important; width: 100% !important; max-width: none !important; box-sizing: border-box !important; `; const cardCss = sel.card ? `${cardRules} { padding: 0 !important; margin: 0 !important; width: 100% !important; box-sizing: border-box !important; }` : ""; this.styleEl.textContent = ` ${sel.cont} { ${baseGridCss} } ${cardCss} ${sel.cont} .inner { padding: 0 !important; } @media (min-width: 768px) { ${sel.cont} { grid-template-columns: repeat(${cols}, 1fr) !important; padding: 1rem 0 !important; } } `; } } AppContainer.register("grid", new GridService()); const UIGallery = { createExtraPreviewsGrid: (previews) => { if (!previews?.length) return null; const hero = previews.find((p) => p.type === "image") || previews[0]; if (!hero) return null; const clips = previews.filter((p) => p !== hero); return h( "div", { className: Config.CLASSES.extraPreviewContainer }, h( "div", { className: "preview-hero-section" }, h( "div", { className: "preview-hero-card", onclick: () => UIGallery.openGallery("", previews, previews.indexOf(hero)) }, hero.type === "image" ? h("img", { src: hero.src, loading: "lazy" }) : h("video", { src: hero.src, autoplay: true, muted: true, loop: true }), h("div", { className: "preview-hero-badge" }, t("mainPreview")) ) ), clips.length > 0 && h( "div", { className: Config.CLASSES.extraPreviewGrid }, ...clips.map((p) => { const idx = previews.indexOf(p); const isVideo = p.type === "video"; return h( "div", { className: `preview-item ${isVideo ? "is-video" : ""}`, onclick: () => UIGallery.openGallery("", previews, idx) }, isVideo ? h("video", { src: p.src, muted: true, onmouseover: (e) => e.target.play(), onmouseout: (e) => { e.target.pause(); e.target.currentTime = 0; } }) : h("img", { src: p.src, loading: "lazy" }), isVideo && h("div", { className: "video-play-hint", innerHTML: IconPlayCircle }) ); }) ) ); }, openGallery: (_id, previews, startIndex = 0) => { let index = startIndex; let isZoomed = false; let isTransitioning = false; let slideshowInterval = null; previews.forEach((item) => { if (item.type === "image") { const img = new Image(); img.src = item.src; } else if (item.type === "video") { const v = document.createElement("video"); v.preload = "metadata"; v.src = item.src; } }); const container2 = h("div", { className: "enh-viewer-backdrop" }); const toggleSlideshow = (btn) => { if (slideshowInterval) { clearInterval(slideshowInterval); slideshowInterval = null; btn.classList.remove("active"); btn.textContent = ""; btn.appendChild(h("span", { innerHTML: IconPlay })); } else { slideshowInterval = window.setInterval(() => { index = (index + 1) % previews.length; render("next"); }, 3e3); btn.classList.add("active"); btn.textContent = ""; btn.appendChild(h("span", { innerHTML: IconPause })); } }; const searchCurrent = () => { const item = previews[index]; if (item && item.type === "image") { const url = EXTERNAL_URLS.GOOGLE_LENS.replace("{url}", encodeURIComponent(item.src)); window.open(url, "_blank"); } }; const closeBtn = h( "div", { className: "enh-viewer-close", onclick: (e) => { e.stopPropagation(); if (slideshowInterval) clearInterval(slideshowInterval); container2.remove(); } }, h("span", { className: "fc2-icon", innerHTML: IconXmark }) ); const btnSlideshow = h("button", { className: "enh-viewer-action pb-play", title: t("gallerySlideshow"), onclick: (e) => { e.stopPropagation(); toggleSlideshow(e.currentTarget); }, innerHTML: IconPlay }); const btnSearch = h("button", { className: "enh-viewer-action pb-search", title: t("gallerySearch"), onclick: (e) => { e.stopPropagation(); searchCurrent(); }, innerHTML: IconImageSearch }); const actionBar = h("div", { className: "enh-viewer-actions" }, btnSlideshow, btnSearch); const counterCurrent = h("span", { style: { color: "#fff", fontWeight: "bold" } }, "1"); const counterTotal = h("span", {}, previews.length.toString()); const counter = h( "div", { className: "enh-viewer-counter" }, counterCurrent, h("span", { style: { opacity: "0.5", margin: "0 4px" } }, "/"), counterTotal ); const stage = h("div", { className: "enh-viewer-stage", onclick: (e) => { if (e.target === stage) { if (slideshowInterval) clearInterval(slideshowInterval); container2.remove(); } } }); const thumbStrip = h( "div", { className: "enh-viewer-thumbs" }, ...previews.map( (p, idx) => h( "div", { className: `enh-thumb-item ${idx === index ? "active" : ""}`, "data-idx": idx, onclick: (e) => { e.stopPropagation(); if (idx === index) return; isZoomed = false; index = idx; render("init"); } }, p.type === "image" ? h("img", { src: p.src }) : h("video", { src: p.src, muted: true }) ) ) ); if (previews.length > 1) { const navPrev = h( "div", { className: "enh-viewer-nav prev", onclick: (e) => { e.stopPropagation(); isZoomed = false; index = (index - 1 + previews.length) % previews.length; render("prev"); } }, h("span", { className: "fc2-icon", style: { transform: "scale(1.5)" }, innerHTML: IconChevronLeft }) ); const navNext = h( "div", { className: "enh-viewer-nav next", onclick: (e) => { e.stopPropagation(); isZoomed = false; index = (index + 1) % previews.length; render("next"); } }, h("span", { className: "fc2-icon", style: { transform: "scale(1.5)" }, innerHTML: IconChevronRight }) ); container2.append(navPrev, navNext); } container2.append(stage, closeBtn, actionBar, counter, thumbStrip); const render = (direction = "init") => { if (isTransitioning) return; isTransitioning = true; const item = previews[index]; if (!item) { isTransitioning = false; return; } counterCurrent.textContent = (index + 1).toString(); if (item.type === "image") { btnSearch.style.display = "flex"; } else { btnSearch.style.display = "none"; } thumbStrip.querySelectorAll(".enh-thumb-item").forEach((el) => el.classList.remove("active")); const activeThumb = thumbStrip.querySelector(`.enh-thumb-item[data-idx="${index}"]`); if (activeThumb) { activeThumb.classList.add("active"); activeThumb.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); } stage.className = `enh-viewer-stage slide-${direction}`; stage.style.cssText = isZoomed ? "overflow: auto; align-items: flex-start; justify-content: flex-start;" : ""; stage.textContent = ""; const media = item.type === "image" ? h("img", { src: item.src, draggable: false, onclick: (e) => { e.stopPropagation(); isZoomed = !isZoomed; render("init"); }, style: isZoomed ? "cursor: zoom-out; max-width: none; max-height: none; width: auto; height: auto; margin: auto; transform: scale(1);" : "cursor: zoom-in; width: 100%; height: 100%; object-fit: contain;" }) : h("video", { src: item.src, controls: true, autoplay: true, loop: true, onclick: (e) => e.stopPropagation(), style: "width: 100%; height: 100%; object-fit: contain; border-radius: 8px;" }); stage.appendChild(media); setTimeout(() => { isTransitioning = false; }, TIMING.UI_TRANSITION_NORMAL); }; const keyHandler = ((e) => { if (!container2.isConnected) { window.removeEventListener("keydown", keyHandler); if (slideshowInterval) clearInterval(slideshowInterval); return; } const key = e.key.toLowerCase(); if (key === "a" || key === "arrowleft") { e.stopPropagation(); isZoomed = false; index = (index - 1 + previews.length) % previews.length; render("prev"); } else if (key === "d" || key === "arrowright") { e.stopPropagation(); isZoomed = false; index = (index + 1) % previews.length; render("next"); } else if (key === "escape") { e.stopPropagation(); if (slideshowInterval) clearInterval(slideshowInterval); container2.remove(); } }); window.addEventListener("keydown", keyHandler); let touchStartX = 0; let touchStartY = 0; let lastTapTime = 0; container2.addEventListener( "touchstart", (e) => { const touch = e.changedTouches[0]; if (touch) { touchStartX = touch.screenX; touchStartY = touch.screenY; } }, { passive: true } ); container2.addEventListener( "touchend", (e) => { const touch = e.changedTouches[0]; if (!touch) return; const touchEndX = touch.screenX; const touchEndY = touch.screenY; const diffX = touchEndX - touchStartX; const diffY = touchEndY - touchStartY; const now = Date.now(); const item = previews[index]; if (item && now - lastTapTime < TIMING.UI_TRANSITION_NORMAL && Math.abs(diffX) < 10 && Math.abs(diffY) < 10) { if (item.type === "image") { e.preventDefault(); isZoomed = !isZoomed; render("init"); } } lastTapTime = now; if (!isZoomed) { if (diffY > UI_CONSTANTS.SWIPE_DISMISS_THRESHOLD && Math.abs(diffX) < 50) { if (slideshowInterval) clearInterval(slideshowInterval); container2.remove(); return; } if (Math.abs(diffX) > 50 && Math.abs(diffY) < 50) { if (diffX > 0) { index = (index - 1 + previews.length) % previews.length; render("prev"); } else { index = (index + 1) % previews.length; render("next"); } } } }, { passive: false } ); setTimeout(() => { const activeThumb = thumbStrip.querySelector(".enh-thumb-item.active"); if (activeThumb) activeThumb.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); }, 50); render(); UIHost.add(container2); } }; const log$s = Logger.scope("ScraperQueue"); class ScraperQueue { constructor(maxConcurrent, scope) { this.maxConcurrent = maxConcurrent; this.scope = scope; this.queue = []; this.activeRequests = 0; } getBackoff() { return Number(Storage.get(`scraper_backoff_${this.scope}_until`, 0)); } getBackoffLevel() { return Number(Storage.get(`scraper_backoff_${this.scope}_level`, 0)); } triggerBackoff(baseDelay, source = "Unknown") { const currentLevel = this.getBackoffLevel(); const exponentialDelay = baseDelay * Math.pow(2, currentLevel); const finalDelay = Math.min(exponentialDelay, TIMING.MAX_BACKOFF_MS); const jitter = Math.min(3e3, finalDelay * 0.1) * Math.random(); const totalDuration = finalDelay + jitter; const until = Date.now() + totalDuration; const nextLevel = Math.min(currentLevel + 1, 6); Storage.set(`scraper_backoff_${this.scope}_until`, until); Storage.set(`scraper_backoff_${this.scope}_level`, nextLevel); log$s.warn(`[${this.scope}] Backoff triggered by ${source} for ${Math.ceil(totalDuration / 1e3)}s (L${nextLevel})`); } resetBackoff() { if (this.getBackoffLevel() > 0) { Storage.set(`scraper_backoff_${this.scope}_level`, 0); log$s.debug(`[${this.scope}] Backoff level reset`); } } resetFullBackoff() { Storage.delete(`scraper_backoff_${this.scope}_until`); Storage.delete(`scraper_backoff_${this.scope}_level`); log$s.debug(`[${this.scope}] Full backoff reset`); } async checkBackoff() { const backoffUntil = this.getBackoff(); const now = Date.now(); if (now < backoffUntil) { return false; } return true; } async waitBackoff() { const backoffUntil = this.getBackoff(); const now = Date.now(); if (now < backoffUntil) { await Utils.sleep(backoffUntil - now); } } enqueue(task) { this.queue.push(task); this.processQueue(); } async processQueue() { if (this.activeRequests >= this.maxConcurrent || this.queue.length === 0) return; this.activeRequests++; const task = this.queue.shift(); try { await task(); } finally { this.activeRequests--; void this.processQueue(); } } } const log$r = Logger.scope("MagnetProvider"); class MagnetProvider { constructor(sukebeiQueue) { this.sukebeiQueue = sukebeiQueue; } async fetchFromSukebei(chunk, type, onResult, traceId) { const query = type === "fc2" ? chunk.join("|") : chunk.flatMap((id) => [id, id.replace(/-/g, "_")]).join("|"); const url = Config.SCRAPER_URLS.SUKEBEI_SEARCH.replace("{query}", encodeURIComponent(query)); try { const rawHtml = await http(url, { type: "text" }); const doc = new DOMParser().parseFromString(rawHtml, "text/html"); if (doc.title && (doc.title.includes("Cloudflare") || doc.title.includes("Attention Required"))) { log$r.warn("Cloudflare blocked, backing off 60s", void 0, traceId); this.sukebeiQueue.triggerBackoff(TIMING.CLOUDFLARE_BACKOFF_MS, "Sukebei (Cloudflare)"); return new Set(); } const rows = doc.querySelectorAll("table.torrent-list tbody tr"); const foundIds = new Set(); const regexes = chunk.map((id) => { const escaped = id.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return { id, regex: new RegExp(`(?<=[^0-9a-zA-Z]|^)${escaped.replace(/[-_\s]/g, "[-_\\s]")}(?=[^0-9a-zA-Z]|$)`, "i"), searchId: id.toUpperCase() }; }); for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row) continue; const magnetAnchor = row.querySelector("a[href^='magnet:?']"); const linkAnchor = row.querySelector('a[href*="/view/"]') || row.querySelector("td:nth-child(2) a"); if (magnetAnchor && linkAnchor) { const title = linkAnchor.textContent?.trim().toUpperCase() || ""; const magnet = magnetAnchor.href; for (const { id, regex, searchId } of regexes) { if (foundIds.has(id)) continue; const match = title.match(regex); if (match) { const idx = match.index; const matchedStr = match[0]; if (!matchedStr) continue; const before = title[idx - 1], after = title[idx + matchedStr.length]; const isDigit = (c) => c !== void 0 && c >= "0" && c <= "9"; if (isDigit(searchId[0]) && isDigit(before)) continue; if (isDigit(searchId[searchId.length - 1]) && isDigit(after)) continue; onResult(id, magnet); foundIds.add(id); } } } } log$r.info(`Sukebei: ${foundIds.size}/${chunk.length} found`, void 0, traceId); this.sukebeiQueue.resetBackoff(); return foundIds; } catch (e) { const err = e; if (err.status === 429) { this.sukebeiQueue.triggerBackoff(TIMING.RATE_LIMIT_BACKOFF_MS, "Sukebei (429)"); } else { log$r.error("Sukebei fetch error", e, traceId); } return new Set(); } } async fetchFrom0cili(id, type, onResult, traceId) { try { const searchQuery = type === "fc2" ? id : id.replace(/-/g, " "); const url = Config.SCRAPER_URLS.OCILI_SEARCH.replace("{query}", encodeURIComponent(searchQuery)); const rawHtml = await http(url, { type: "text" }); const doc = new DOMParser().parseFromString(rawHtml, "text/html"); const rows = doc.querySelectorAll("table tbody tr, .torrent-list tr"); let found = false; const searchId = id.toUpperCase(); const regex = new RegExp(`(?<=[^0-9a-zA-Z]|^)${searchId.replace(/[-_\s]/g, "[-_\\s]")}(?=[^0-9a-zA-Z]|$)`, "i"); for (const row of Array.from(rows)) { const magnetAnchor = row.querySelector("a[href^='magnet:?']"); const titleElement = row.querySelector("a[title], td:nth-child(2) a, .torrent-name a"); if (magnetAnchor && titleElement) { const title = (titleElement.getAttribute("title") || titleElement.textContent || "").trim().toUpperCase(); if (regex.test(title)) { onResult(id, magnetAnchor.href); found = true; log$r.info(`0cili: Found magnet for ${id}`, void 0, traceId); break; } } } if (!found) { onResult(id, null); log$r.debug(`0cili: No magnet for ${id}`, void 0, traceId); } await Utils.sleep(TIMING.OCILI_DELAY_MS + Math.random() * TIMING.OCILI_RANDOM_DELAY_MS); } catch (e) { log$r.error(`0cili fetch error for ${id}`, e, traceId); onResult(id, null); } } } const log$q = Logger.scope("PreviewProvider"); class PreviewProvider { constructor(wumaobiQueue) { this.wumaobiQueue = wumaobiQueue; } async fetchExtraPreviews(fc2Id) { try { const normalizedId = fc2Id.padStart(7, "0"); const idPattern = `${PATTERNS.FC2_PPV_PREFIX}${normalizedId}`; const url = Config.SCRAPER_URLS.WUMAOBI_DETAIL.replace("{id}", normalizedId); const rawHtml = await http(url, { type: "text" }); const doc = new DOMParser().parseFromString(rawHtml, "text/html"); const results = []; const blacklist = PREVIEW_BLACKLIST; doc.querySelectorAll("img").forEach((img) => { let src = img.getAttribute("src"); if (src) { const lowSrc = src.toLowerCase(); const isBad = blacklist.some((key) => lowSrc.includes(key)); const isRelevant = src.includes(idPattern) || src.includes(fc2Id); if (!isBad && isRelevant) { if (src.startsWith("/")) src = Config.SCRAPER_URLS.WUMAOBI_BASE + src; results.push({ type: "image", src }); } } }); doc.querySelectorAll("video").forEach((video) => { let src = video.getAttribute("src") || video.querySelector("source")?.getAttribute("src"); if (src) { const isRelevant = src.includes(idPattern) || src.includes(fc2Id); if (isRelevant) { if (src.startsWith("/")) src = Config.SCRAPER_URLS.WUMAOBI_BASE + src; results.push({ type: "video", src }); } } }); return results; } catch (err) { const e = err; if (e.status === 404) { log$q.debug(`No previews found on Wumaobi for ${fc2Id}`); } else if (e.status === 429 || e.status === 503) { this.wumaobiQueue.triggerBackoff(TIMING.RATE_LIMIT_BACKOFF_MS, `Wumaobi (${e.status})`); } else { log$q.warn(`Failed to fetch extra previews: ${fc2Id} (Status: ${e.status || "Unknown"})`); } return []; } } } const log$p = Logger.scope("ActressProvider"); class ActressProvider { async fetchActressFromFD2(id) { const cacheKey = `actress_${id}`; const cached = await Repository.cache.get(cacheKey); if (cached) { const cleanCached = MediaUtils.cleanActressName(cached); if (cleanCached) { log$p.debug(`Actress cache hit: ${id} = ${cleanCached}`); return cleanCached; } else { log$p.warn(`Removing invalid cached actress for ${id}: ${cached}`); await Repository.cache.delete(cacheKey); } } const backoffEnd = Number(Storage.get("fd2_backoff_until", 0)); if (Date.now() < backoffEnd) { log$p.debug(`Skipping FD2 request, backoff active (${Math.ceil((backoffEnd - Date.now()) / 1e3)}s remaining)`); return null; } const now = Date.now(); const lastFetch = Number(Storage.get("last_actress_fetch", 0)); const waitTime = Math.max(0, lastFetch + TIMING.ACTRESS_BASE_DELAY_MS + Math.random() * TIMING.ACTRESS_RANDOM_DELAY_MS - now); if (waitTime > 0) await Utils.sleep(waitTime); Storage.set("last_actress_fetch", Date.now()); try { const url = EXTERNAL_URLS.FD2PPV.replace("{id}", id); const html = await http(url, { type: "text" }); const doc = new DOMParser().parseFromString(html, "text/html"); if (doc.title && CLOUDFLARE_INDICATORS.some((s) => doc.title.includes(s))) { log$p.warn("FD2PPV Cloudflare detected, backing off 5m"); Storage.set("fd2_backoff_until", Date.now() + TIMING.FD2_BACKOFF_MS); return null; } const actressLinks = Array.from(doc.querySelectorAll(".artist-info-card .artist-name a")); let actress = null; for (const link of actressLinks) { const cleaned = MediaUtils.cleanActressName(link.textContent); if (cleaned) { actress = cleaned; break; } } if (actress) { await Repository.cache.set(cacheKey, actress); log$p.info(`Fetched actress: ${id} = ${actress}`); } return actress; } catch (e) { const err = e; if (err.status === 403 || err.status === 429 || err.status === 503) { log$p.warn(`FD2PPV error (${err.status}), backing off 5m`); Storage.set("fd2_backoff_until", Date.now() + TIMING.FD2_BACKOFF_MS); } else { log$p.error(`Failed to fetch actress: ${id}`, e); } return null; } } resetBackoff() { Storage.delete("fd2_backoff_until"); log$p.debug("FD2PPV backoff reset"); } } const log$o = Logger.scope("Scraper"); class ScraperServiceImplementation { constructor() { this.MAX_MAGNET_CONCURRENT = 3; this.MAX_PREVIEW_CONCURRENT = 2; this.magnetQueue = new ScraperQueue(this.MAX_MAGNET_CONCURRENT, "sukebei"); this.previewQueue = new ScraperQueue(this.MAX_PREVIEW_CONCURRENT, "wumaobi"); this.magnetProvider = new MagnetProvider(this.magnetQueue); this.previewProvider = new PreviewProvider(this.previewQueue); this.actressProvider = new ActressProvider(); } onBootstrap() { log$o.debug("Scraper service bootstrapped, queues ready"); } async fetchMagnets(items, onResult) { if (!items?.length) return; const traceId = Logger.traceId; log$o.info(`Starting magnet fetch for ${items.length} items`, { ids: items.map((i) => i.id) }, traceId); const grouped = new Map(); items.forEach((item) => { const key = item.type || "fc2"; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(item); }); const pendingPromises = []; for (const [type, itemList] of grouped) { const ids = itemList.map((i) => i.id); for (const chunk of Utils.chunk(ids, NETWORK.CHUNK_SIZE)) { pendingPromises.push( new Promise((resolve) => { this.magnetQueue.enqueue(async () => { await this.magnetQueue.waitBackoff(); const delay = TIMING.MAGNET_BASE_DELAY_MS + Math.random() * TIMING.MAGNET_RANDOM_DELAY_MS; await Utils.sleep(delay); const foundIds = await this.magnetProvider.fetchFromSukebei(chunk, type, onResult, traceId); const failedIds = chunk.filter((id) => !foundIds.has(id)); if (failedIds.length > 0) { await Promise.all( failedIds.map( (id) => this.magnetProvider.fetchFrom0cili(id, type, onResult, traceId).catch(() => { }) ) ); } resolve(); }); }) ); } } await Promise.all(pendingPromises); } async fetchActressFromFD2(id) { return this.actressProvider.fetchActressFromFD2(id); } resetFD2Backoff() { this.actressProvider.resetBackoff(); } async fetchExtraPreviews(fc2Id) { return this.previewProvider.fetchExtraPreviews(fc2Id); } async checkPreviewExists(fc2Id) { const cacheKey = `has_previews_${fc2Id}`; const cached = await Repository.cache.get(cacheKey); if (cached !== null) return cached === true; return new Promise((resolve) => { this.previewQueue.enqueue(async () => { const canProceed = await this.previewQueue.checkBackoff(); if (!canProceed) { resolve(false); return; } const jitter = Math.random() * 500; await Utils.sleep(TIMING.POLITE_DELAY_MS + jitter); try { const results = await this.fetchExtraPreviews(fc2Id); const exists = results.length > 0; if (exists) this.previewQueue.resetBackoff(); await Repository.cache.set(cacheKey, exists); resolve(exists); } catch { resolve(false); } }); }); } } const ScraperService = AppContainer.register("scraper-service", new ScraperServiceImplementation()); const Button$1 = (iconSvg, tip, href, onClick, className = "") => { const children = []; if (iconSvg) { children.push(UIUtils.icon(iconSvg)); } children.push( h("span", { className: Config.CLASSES.buttonText }, tip), h("span", { className: Config.CLASSES.tooltip }, tip) ); const b = h( "a", { href: href || "javascript:void(0);", className: `${Config.CLASSES.resourceBtn} ${className}`.trim(), role: "button", tabIndex: 0, onclick: (e) => { if (onClick) { e.preventDefault(); e.stopPropagation(); onClick(e); } }, onkeydown: (e) => { if (e.key === "Enter" || e.key === " ") { if (onClick) { e.preventDefault(); e.stopPropagation(); onClick(e); } } } }, ...children ); return b; }; const MagnetButton = (cont, url) => { if (cont && !cont.querySelector(`.${Config.CLASSES.btnMagnet}`)) { const btn = Button$1(IconMagnet, t("tooltipCopyMagnet"), "javascript:void(0);", (e) => { e.preventDefault(); UIUtils.copyButtonBehavior(btn, url, t("tooltipCopied")); }); btn.classList.add(Config.CLASSES.btnMagnet); cont.appendChild(btn); const card2 = cont.closest(`.${Config.CLASSES.cardRebuilt}`); if (card2) { UIUtils.applyCardVisibility(card2, true); } } }; var PageContext = ((PageContext2) => { PageContext2["Unknown"] = "unknown"; PageContext2["List"] = "list"; PageContext2["Detail"] = "detail"; PageContext2["User"] = "user"; PageContext2["Search"] = "search"; return PageContext2; })(PageContext || {}); const SYSTEM_FOLDERS = ["wanted", "viewed", "follow"]; const log$n = Logger.scope("Registry"); class Registry { constructor() { this.components = new Map(); this.elementToComponents = new Map(); this.observer = null; this.initGC(); } initGC() { if (typeof MutationObserver === "undefined") return; this.observer = new MutationObserver((mutations) => { mutations.forEach((m) => { if (m.removedNodes.length > 0) { m.removedNodes.forEach((node) => { if (node instanceof HTMLElement) { this.gc(node); } }); } }); }); setTimeout(() => { this.observer?.observe(document.body, { childList: true, subtree: true }); }, 2e3); } gc(root) { this.elementToComponents.forEach((set, el) => { if (root === el || root.contains(el)) { Array.from(set).forEach((ins) => { Logger.trace("Registry", `Auto-GC: detached ${ins.id}`); this.unregister(ins); }); } }); } register(component) { if (!this.components.has(component.id)) { this.components.set(component.id, new Set()); } this.components.get(component.id).add(component); if (component.element) { if (!this.elementToComponents.has(component.element)) { this.elementToComponents.set(component.element, new Set()); } this.elementToComponents.get(component.element).add(component); } Logger.trace("Registry", `Registered: ${component.id}`); } unregister(component) { const set = this.components.get(component.id); if (set) { set.delete(component); if (set.size === 0) this.components.delete(component.id); } if (component.element) { const elSet = this.elementToComponents.get(component.element); if (elSet) { elSet.delete(component); if (elSet.size === 0) this.elementToComponents.delete(component.element); } } if (component.destroy) { try { component.destroy(); } catch (err) { log$n.warn(`Failed to destroy component ${component.id}`, err); } delete component.destroy; } } getInstances(id) { const set = this.components.get(id); return set ? Array.from(set) : []; } notify(id, data) { const instances = this.getInstances(id); instances.forEach((ins) => { try { ins.update(data); } catch (e) { log$n.error(`Notify failed for ${id}`, e); } }); } clear() { this.components.forEach((set) => { set.forEach((ins) => this.unregister(ins)); }); this.components.clear(); this.elementToComponents.clear(); log$n.debug("Component registry cleared"); } } const ComponentRegistry = new Registry(); const log$m = Logger.scope("ViewStore"); class ViewStoreImpl { constructor() { this.states = new Map(); } getDefaults() { return { isSearching: false, hasMagnet: false, hasPreviews: false }; } get(id) { if (!this.states.has(id)) { this.states.set(id, this.getDefaults()); } return this.states.get(id); } set(id, key, value) { const current = this.get(id); if (current[key] === value) return; current[key] = value; log$m.trace(`[${id}] ${String(key)} = ${value}`); CoreEvents.emit(AppEvents.VIEW_STATE_CHANGED, { id, key: String(key), value }); ComponentRegistry.notify(id, { key, value }); } update(id, updates) { Object.entries(updates).forEach(([key, value]) => { this.set(id, key, value); }); } clear() { this.states.clear(); log$m.debug("All view states cleared"); } } const ViewStore = new ViewStoreImpl(); class UndoManager { constructor(maxSize = 30) { this.stack = []; this.maxSize = maxSize; } push(action) { this.stack.push({ ...action, timestamp: Date.now() }); if (this.stack.length > this.maxSize) { this.stack.shift(); } } pop() { return this.stack.pop(); } peek() { return this.stack[this.stack.length - 1]; } canUndo() { return this.stack.length > 0; } getLabel() { const last = this.peek(); return last?.label ?? null; } clear() { this.stack = []; } } const log$l = Logger.scope("CollectionQuery"); class CollectionQueryService { async getCollectionItems(filter, sort) { try { let items = await Repository.details.getAll(); if (filter) { items = this.applyFilter(items, filter); } if (sort) { items = this.applySort(items, sort); } return items; } catch (e) { log$l.error("Failed to fetch collection items", e); return []; } } async getCollectionItemsByFolder(folder) { if (!folder) return this.getCollectionItems(); return this.getCollectionItems({ folder }); } applyFilter(items, filter) { return items.filter((item) => { if (filter.folder && filter.folder !== "all") { if ((item.folder || "wanted") !== filter.folder) return false; } if (filter.site && filter.site !== "all") { if ((item.type || "fc2").toLowerCase() !== filter.site.toLowerCase()) return false; } if (filter.hasRating && !item.rating) return false; if (filter.hasNotes && !item.notes) return false; if (filter.hasTags && (!item.userTags || item.userTags.length === 0)) return false; if (filter.minRating && (item.rating || 0) < filter.minRating) return false; if (filter.tags && filter.tags.length > 0) { const itemTags = new Set(item.userTags || []); if (!filter.tags.some((t2) => itemTags.has(t2))) return false; } return true; }); } applySort(items, sort) { const sorted = [...items]; switch (sort) { case "date-desc": return sorted.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0)); case "date-asc": return sorted.sort((a, b) => (a.lastAccessed || 0) - (b.lastAccessed || 0)); case "title": return sorted.sort((a, b) => (a.title || "").localeCompare(b.title || "")); case "site": return sorted.sort((a, b) => (a.type || "").localeCompare(b.type || "")); case "rating": return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0)); case "notes": return sorted.sort((a, b) => { const aN = a.notes ? 1 : 0; const bN = b.notes ? 1 : 0; return bN - aN || (b.lastAccessed || 0) - (a.lastAccessed || 0); }); case "folder": return sorted.sort((a, b) => (a.folder || "wanted").localeCompare(b.folder || "wanted")); default: return sorted; } } async getStats() { const items = await Repository.details.getAll(); const folderCounts = {}; const tagCounts = {}; const siteDistribution = {}; let ratedCount = 0; let totalRating = 0; let withNotes = 0; let withTags = 0; let oldestItem = Infinity; let newestItem = 0; for (const item of items) { const folder = item.folder || "wanted"; folderCounts[folder] = (folderCounts[folder] || 0) + 1; if (item.userTags && item.userTags.length > 0) { withTags++; for (const tag of item.userTags) { tagCounts[tag] = (tagCounts[tag] || 0) + 1; } } if (item.rating && item.rating > 0) { ratedCount++; totalRating += item.rating; } if (item.notes) withNotes++; const site = (item.type || "fc2").toLowerCase(); siteDistribution[site] = (siteDistribution[site] || 0) + 1; const ts = item.addedAt || item.lastAccessed || 0; if (ts < oldestItem) oldestItem = ts; if (ts > newestItem) newestItem = ts; } return { totalItems: items.length, folderCounts, tagCounts, averageRating: ratedCount > 0 ? totalRating / ratedCount : 0, ratedCount, withNotes, withTags, siteDistribution, oldestItem: oldestItem === Infinity ? 0 : oldestItem, newestItem }; } async getAllTags() { const stats = await this.getStats(); return Object.entries(stats.tagCounts).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count); } async getFolders() { const items = await Repository.details.getAll(); const folders = new Set(SYSTEM_FOLDERS); items.forEach((i) => { if (i.folder) folders.add(i.folder); }); return Array.from(folders).sort((a, b) => { const aIsSystem = SYSTEM_FOLDERS.includes(a) ? 0 : 1; const bIsSystem = SYSTEM_FOLDERS.includes(b) ? 0 : 1; if (aIsSystem !== bIsSystem) return aIsSystem - bIsSystem; return a.localeCompare(b); }); } async getFolderCounts() { const items = await Repository.details.getAll(); const counts = {}; for (const folder of SYSTEM_FOLDERS) { counts[folder] = 0; } items.forEach((item) => { const f = item.folder || "wanted"; counts[f] = (counts[f] || 0) + 1; }); return counts; } async findDuplicates() { const items = await this.getCollectionItems(); const groups = new Map(); items.forEach((item) => { let key = item.id.toLowerCase(); const fc2Match = item.id.match(/\d{5,8}/); if (fc2Match) key = `fc2-${fc2Match[0]}`; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(item); }); return Array.from(groups.values()).filter((g) => g.length > 1); } } const log$k = Logger.scope("CollectionHealth"); class CollectionHealthService { constructor() { this.isCheckingHealth = false; this.HEALTH_CHECK_CONCURRENCY = 5; this.STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3; } async runHealthCheck(force = false) { if (this.isCheckingHealth) return { checked: 0, repaired: 0 }; this.isCheckingHealth = true; log$k.debug("Starting health check"); let totalChecked = 0; let repairedCount = 0; try { const items = await Repository.details.getAll(); const now = Date.now(); const toCheck = items.filter((item) => force || !item.lastCheck || now - item.lastCheck > this.STALE_THRESHOLD_MS); const total = toCheck.length; log$k.debug(`Checking ${total} items for health`); let processed = 0; const pool = new Set(); for (const item of toCheck) { if (pool.size >= this.HEALTH_CHECK_CONCURRENCY) { await Promise.race(pool); } const promise = (async () => { const isOk = await this.verifyImageUrl(item.primaryImageUrl || item.imageUrl); if (!isOk) { const repaired = await this.repairCover(item); if (repaired) repairedCount++; } else { await Repository.details.batchUpdate([item.id], { lastCheck: now }); } processed++; CoreEvents.emit(AppEvents.COLLECTION_HEALTH_PROGRESS, { processed, total, repairedCount }); })(); pool.add(promise); promise.finally(() => pool.delete(promise)); } await Promise.all(pool); totalChecked = total; if (repairedCount > 0) { log$k.info(`Health check complete, repaired ${repairedCount}/${total} covers`); } else { log$k.info(`Health check complete, all ${total} items healthy`); } } catch (e) { log$k.error("Health check failed", e); } finally { this.isCheckingHealth = false; } return { checked: totalChecked, repaired: repairedCount }; } async verifyImageUrl(url) { if (!url || url.includes("placehold.co")) return false; try { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "HEAD", url, timeout: 5e3, onload: (res) => resolve(res.status >= 200 && res.status < 400), onerror: () => resolve(false), ontimeout: () => resolve(false) }); }); } catch { return false; } } async repairCover(item) { const id = item.id; const mirrors = [ EXTERNAL_URLS.WUMAOBI_COVER.replace("{id}", id), EXTERNAL_URLS.WUMAOBI_COVER.replace("{id}", id).replace(PATTERNS.WUMAOBI_COVER, PATTERNS.WUMAOBI_MAIN), EXTERNAL_URLS.FOURHOI_COVER.replace("{id}", id.padStart(7, "0")) ]; for (const mirror of mirrors) { if (mirror === (item.primaryImageUrl || item.imageUrl)) continue; const isOk = await this.verifyImageUrl(mirror); if (isOk) { await Repository.details.batchUpdate([id], { primaryImageUrl: mirror, lastCheck: Date.now() }); await Repository.history.markDirty(id); log$k.debug(`Repaired cover for ${id}`); return true; } } await Repository.details.batchUpdate([id], { lastCheck: Date.now() }); return false; } } const log$j = Logger.scope("Collection"); const COLLECTION_EXPORT_VERSION = 1; class CollectionServiceImplementation { constructor() { this.collectionCache = new Set(); this.queryService = new CollectionQueryService(); this.healthService = new CollectionHealthService(); this.undoManager = new UndoManager(); } onInit() { CoreEvents.on(AppEvents.HISTORY_LOADED, () => this.syncFromHistory()); } onBootstrap() { log$j.debug("Bootstrapping cache"); this.syncWithCollection(); } async syncWithCollection() { Logger.time("CollectionService.sync"); this.collectionCache.clear(); try { const items = await Repository.details.getAll(); for (const item of items) { this.collectionCache.add(item.id); } } catch (e) { log$j.error("Failed to sync collection cache", e); } Logger.timeEnd("CollectionService.sync"); log$j.info(`Warmed up with ${this.collectionCache.size} collected items`); } syncFromHistory() { const cache = HistoryService.getCache(); for (const [id, status] of cache) { if (status === "wanted" && !this.collectionCache.has(id)) { this.collectionCache.add(id); } } } normalizeId(id) { return IdNormalizer.normalize(id); } folderToStatus(folder) { const VALID_STATUSES = new Set(["watched", "wanted", "downloaded", "blocked"]); return VALID_STATUSES.has(folder) ? folder : "wanted"; } notifyUI(id, isWanted) { ViewStore.set(id, "isWanted", isWanted); } emitUpdate(id, type) { CoreEvents.emit(AppEvents.COLLECTION_UPDATED, { id, type }); } async add(id, metadata, folder = "wanted") { const nid = this.normalizeId(id); if (this.collectionCache.has(nid)) { if (folder && folder !== "wanted") { await this.moveToFolder(nid, folder); } return false; } try { this.collectionCache.add(nid); this.notifyUI(nid, true); const now = Date.now(); const finalMetadata = { id: nid, type: "fc2", ...metadata, updated_at: ( new Date()).toISOString(), lastAccessed: now, addedAt: metadata?.addedAt ?? now, folder }; await Repository.details.set(nid, finalMetadata); this.undoManager.push({ type: "add", label: `Add ${nid}`, snapshots: [{ id: nid, detail: finalMetadata }] }); HistoryService.add(nid, this.folderToStatus(folder)); this.emitUpdate(nid, "add"); log$j.debug(`Added ${nid} to folder '${folder}'`); return true; } catch (e) { log$j.error(`Failed to add ${nid}`, e); this.collectionCache.delete(nid); this.notifyUI(nid, false); return false; } } async remove(id) { const nid = this.normalizeId(id); if (!this.collectionCache.has(nid)) return false; const snapshot = await Repository.details.get(nid); this.collectionCache.delete(nid); this.notifyUI(nid, false); await Repository.details.remove(nid); HistoryService.remove(nid); if (snapshot) { this.undoManager.push({ type: "remove", label: `Remove ${nid}`, snapshots: [{ id: nid, detail: snapshot }] }); } this.emitUpdate(nid, "remove"); return true; } async toggle(id, metadata) { const nid = this.normalizeId(id); const wasCollected = this.has(nid); if (wasCollected) { await this.remove(nid); } else { await this.add(nid, metadata); } return !wasCollected; } has(id) { return this.collectionCache.has(this.normalizeId(id)); } getCount() { return this.collectionCache.size; } getIds() { return Array.from(this.collectionCache); } async getCollectionItems(filter, sort) { return this.queryService.getCollectionItems(filter, sort); } async getCollectionItemsByFolder(folder) { return this.queryService.getCollectionItemsByFolder(folder); } async getStats() { return this.queryService.getStats(); } async getAllTags() { return this.queryService.getAllTags(); } async getFolders() { return this.queryService.getFolders(); } async getFolderCounts() { return this.queryService.getFolderCounts(); } async findDuplicates() { return this.queryService.findDuplicates(); } async runHealthCheck(force = false) { return this.healthService.runHealthCheck(force); } canUndo() { return this.undoManager.canUndo(); } getUndoLabel() { return this.undoManager.getLabel(); } async undo() { const action = this.undoManager.pop(); if (!action) return false; log$j.debug(`Undoing: ${action.label}`); try { switch (action.type) { case "add": for (const { id } of action.snapshots) { this.collectionCache.delete(id); this.notifyUI(id, false); await Repository.details.remove(id); } break; case "remove": case "batch-remove": for (const { id, detail } of action.snapshots) { this.collectionCache.add(id); this.notifyUI(id, true); await Repository.details.set(id, detail); HistoryService.add(id, this.folderToStatus(detail.folder || "wanted")); } break; case "move": case "batch-move": for (const { id, detail } of action.snapshots) { const prevFolder = detail.folder || "wanted"; await Repository.details.updateFolder(id, prevFolder); await Repository.history.markDirty(id); } break; case "metadata": for (const { id, detail } of action.snapshots) { await Repository.details.set(id, { rating: detail.rating, notes: detail.notes, userTags: detail.userTags }); await Repository.history.markDirty(id); } break; case "merge": for (const { id, detail } of action.snapshots) { this.collectionCache.add(id); this.notifyUI(id, true); await Repository.details.set(id, detail); HistoryService.add(id, this.folderToStatus(detail.folder || "wanted")); } break; } this.emitUpdate(void 0, "undo"); log$j.info(`Undo completed: ${action.label}`); return true; } catch (e) { log$j.error(`Undo failed: ${action.label}`, e); return false; } } async updateMetadata(id, metadata) { const nid = this.normalizeId(id); const item = await Repository.details.get(nid); if (!item) return false; const snapshot = { ...item }; if (metadata.userTags) { metadata.userTags = metadata.userTags.map((tag) => tag.trim()).filter((tag) => tag.length > 0).filter((tag, i, arr) => arr.indexOf(tag) === i); } if (metadata.rating !== void 0) { metadata.rating = Math.max(0, Math.min(5, Math.round(metadata.rating))); } await Repository.details.set(nid, { ...metadata }); await Repository.history.markDirty(nid); this.undoManager.push({ type: "metadata", label: `Edit metadata: ${nid}`, snapshots: [{ id: nid, detail: snapshot }] }); this.emitUpdate(nid, "metadata"); return true; } async getItem(id) { return Repository.details.get(this.normalizeId(id)); } async moveToFolder(id, folder) { const nid = this.normalizeId(id); try { const item = await Repository.details.get(nid); const prevFolder = item?.folder || "wanted"; await Repository.details.updateFolder(nid, folder); await Repository.history.markDirty(nid); if (item) { this.undoManager.push({ type: "move", label: `Move ${nid}: ${prevFolder} → ${folder}`, snapshots: [{ id: nid, detail: item }] }); } this.emitUpdate(nid, "move"); log$j.debug(`Moved ${nid} to ${folder}`); return true; } catch (e) { log$j.error(`Failed to move ${nid} to ${folder}`, e); return false; } } async batchMoveToFolder(ids, folder) { const snapshots = []; const errors = []; for (const id of ids) { const detail = await Repository.details.get(id); if (detail) snapshots.push({ id, detail: { ...detail } }); } const nids = ids.map((id) => this.normalizeId(id)); try { await Repository.details.batchUpdate(nids, { folder }); for (const nid of nids) { await Repository.history.markDirty(nid); } this.undoManager.push({ type: "batch-move", label: `Batch move ${nids.length} items → ${folder}`, snapshots, extra: { targetFolder: folder } }); this.emitUpdate(void 0, "batch-move"); log$j.info(`Batch moved ${nids.length} items to ${folder}`); } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; errors.push(msg); log$j.error("Batch move failed", e); } return { success: errors.length === 0, affected: nids.length, errors }; } async renameFolder(oldName, newName) { if (!oldName || !newName || oldName === newName) { return { success: false, affected: 0, errors: ["Invalid folder names"] }; } if (SYSTEM_FOLDERS.includes(oldName)) { return { success: false, affected: 0, errors: ["Cannot rename system folder"] }; } try { const items = await Repository.details.getAll(); const targetIds = items.filter((i) => i.folder === oldName).map((i) => i.id); if (targetIds.length > 0) { await this.batchMoveToFolder(targetIds, newName); log$j.info(`Renamed folder '${oldName}' to '${newName}' (${targetIds.length} items)`); } return { success: true, affected: targetIds.length, errors: [] }; } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; log$j.error("Failed to rename folder", e); return { success: false, affected: 0, errors: [msg] }; } } async deleteFolder(folderName) { if (SYSTEM_FOLDERS.includes(folderName)) { return { success: false, affected: 0, errors: ["Cannot delete system folder"] }; } return this.renameFolder(folderName, "wanted"); } async batchRemove(ids) { const snapshots = []; const errors = []; for (const id of ids) { const detail = await Repository.details.get(id); if (detail) snapshots.push({ id, detail: { ...detail } }); } const nids = ids.map((id) => this.normalizeId(id)); try { for (const nid of nids) { this.collectionCache.delete(nid); this.notifyUI(nid, false); } await Repository.details.batchDelete(nids); this.undoManager.push({ type: "batch-remove", label: `Batch remove ${nids.length} items`, snapshots }); this.emitUpdate(void 0, "batch-remove"); log$j.info(`Batch removed ${nids.length} items`); } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; errors.push(msg); log$j.error("Batch remove failed", e); } return { success: errors.length === 0, affected: nids.length, errors }; } async clearAll() { try { const ids = this.getIds(); const count = ids.length; await Repository.details.clear(); this.collectionCache.clear(); this.emitUpdate(void 0, "clear"); log$j.info(`Collection cleared (${count} items)`); return { success: true, affected: count, errors: [] }; } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; log$j.error("Failed to clear collection", e); return { success: false, affected: 0, errors: [msg] }; } } async mergeItems(masterId, dupeIds) { const master = await Repository.details.get(masterId); if (!master) { return { success: false, affected: 0, errors: ["Master item not found"] }; } const mergeSnapshots = []; let mergedCount = 0; for (const id of dupeIds) { if (id === masterId) continue; const dupe = await Repository.details.get(id); if (!dupe) continue; mergeSnapshots.push({ id, detail: { ...dupe } }); if (!master.title && dupe.title) master.title = dupe.title; if (!master.imageUrl && dupe.imageUrl) master.imageUrl = dupe.imageUrl; if (!master.primaryImageUrl && dupe.primaryImageUrl) master.primaryImageUrl = dupe.primaryImageUrl; if (dupe.rating && (!master.rating || dupe.rating > master.rating)) master.rating = dupe.rating; if (dupe.notes) { master.notes = (master.notes ? master.notes + "\n\n" : "") + `[Merged from ${id}]: ${dupe.notes}`; } if (dupe.userTags && dupe.userTags.length > 0) { const existingTags = new Set(master.userTags || []); dupe.userTags.forEach((tag) => existingTags.add(tag)); master.userTags = Array.from(existingTags); } if (dupe.addedAt && (!master.addedAt || dupe.addedAt < master.addedAt)) { master.addedAt = dupe.addedAt; } await this.remove(id); this.undoManager.pop(); mergedCount++; } master.updated_at = ( new Date()).toISOString(); await Repository.details.set(masterId, master); await Repository.history.markDirty(masterId); if (mergeSnapshots.length > 0) { this.undoManager.push({ type: "merge", label: `Merge ${mergedCount} items into ${masterId}`, snapshots: mergeSnapshots }); } this.emitUpdate(masterId, "merge"); return { success: true, affected: mergedCount, errors: [] }; } async exportCollection() { const items = await Repository.details.getAll(); const folders = await this.getFolders(); const folderCounts = await this.getFolderCounts(); return { format: "fc2ppvdb-collection", version: COLLECTION_EXPORT_VERSION, exportedAt: ( new Date()).toISOString(), stats: { totalItems: items.length, folderCounts }, items, folders }; } async importCollection(data) { if (data.format !== "fc2ppvdb-collection") { return { success: false, affected: 0, errors: ["Invalid collection format"] }; } const errors = []; let imported = 0; for (const item of data.items) { try { const nid = this.normalizeId(item.id); const existing = await Repository.details.get(nid); if (existing) { if (item.updated_at > existing.updated_at) { await Repository.details.set(nid, { ...item, id: nid }); imported++; } } else { await this.add(nid, { ...item, id: nid }, item.folder || "wanted"); this.undoManager.pop(); imported++; } } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; errors.push(`${item.id}: ${msg}`); } } this.emitUpdate(void 0, "import"); log$j.info(`Imported ${imported} items (${errors.length} errors)`); return { success: errors.length === 0, affected: imported, errors }; } } const CollectionService = AppContainer.register("collection-service", new CollectionServiceImplementation()); const StatusToggle = (id, type, metadata) => { const C = Config.CLASSES; let isActive = false; if (type === "wanted") { isActive = CollectionService.has(id); } else { isActive = HistoryService.getStatus(id) === type; } let iconOn, iconOff, tooltipOn, tooltipOff; let className; let activeClass; let iconClass; switch (type) { case "watched": iconOn = IconEye; iconOff = IconEyeSlash; tooltipOn = t("tooltipMarkAsUnviewed"); tooltipOff = t("tooltipMarkAsViewed"); className = "btn-toggle-view"; activeClass = "is-viewed"; iconClass = "icon-viewed"; break; case "wanted": iconOn = IconStar; iconOff = IconStarRegular; tooltipOn = t("tooltipMarkAsUnwanted"); tooltipOff = t("tooltipMarkAsWanted"); className = "btn-toggle-wanted"; activeClass = "is-wanted"; iconClass = "icon-wanted"; break; case "blocked": iconOn = IconBan; iconOff = IconBan; tooltipOn = t("tooltipMarkAsUnblocked"); tooltipOff = t("tooltipMarkAsBlocked"); className = "btn-toggle-blocked"; activeClass = "is-blocked"; iconClass = "icon-blocked"; break; } const btn = Button$1( iconOn, isActive ? tooltipOn : tooltipOff, "javascript:void(0);", (_e) => { if (type === "wanted") { CollectionService.toggle(id, metadata); } else { const currentlyActive = btn.classList.contains(activeClass); if (currentlyActive) { HistoryService.remove(id); } else { HistoryService.add(id, type); } } }, `${className} ${isActive ? activeClass : ""}` ); if (iconOn !== iconOff) { const firstIcon = btn.querySelector(".fc2-icon"); if (firstIcon && iconClass) firstIcon.classList.add(iconClass); const iconOffClass = `icon-un${type === "watched" ? "viewed" : type}`; const iconOffEl = UIUtils.icon(iconOff, iconOffClass); firstIcon?.after(iconOffEl); } const component = { id, element: btn, update: (raw) => { const data = raw; if (!data) return; const isTargetProp = type === "wanted" ? data.key === "isWanted" : data.key === "status"; if (isTargetProp) { const newValue = data.value; const newActive = type === "wanted" ? !!newValue : newValue === type; btn.classList.toggle(activeClass, newActive); if (newActive) { btn.classList.add("fc2-animate-pop"); if (type === "wanted") { btn.classList.add("fc2-star-burst"); setTimeout(() => btn.classList.remove("fc2-star-burst"), TIMING.UI_TRANSITION_SLOW); } setTimeout(() => btn.classList.remove("fc2-animate-pop"), TIMING.UI_TRANSITION_NORMAL); } const currentTip = newActive ? tooltipOn : tooltipOff; const tt = btn.querySelector(`.${C.tooltip}`); if (tt) tt.textContent = currentTip; const btnTxt = btn.querySelector(`.${C.buttonText}`); if (btnTxt) btnTxt.textContent = currentTip; } } }; ComponentRegistry.register(component); return component; }; const ActressButton = (cont, actress) => { if (!cont || !actress || cont.querySelector(".btn-actress")) return; const actBtn = h( "button", { className: `${Config.CLASSES.resourceBtn} btn-actress`, onclick: (e) => { e.preventDefault(); e.stopPropagation(); UIUtils.copyButtonBehavior(actBtn, actress, t("tooltipCopied")); } }, actress ); const leftActions = cont.querySelector(".card-left-actions"); const links = cont.querySelector(`.${Config.CLASSES.resourceLinksContainer}`); if (leftActions) cont.insertBefore(actBtn, leftActions); else if (links) cont.insertBefore(actBtn, links); else cont.appendChild(actBtn); }; const _ActionSheet = class _ActionSheet { static show(title, options) { if (!this.backdrop) { this.backdrop = h("div", { className: "fc2-action-sheet-backdrop", role: "none", "aria-hidden": "true" }); this.sheet = h("div", { className: "fc2-action-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": "fc2-sheet-title" }); UIHost.add(this.backdrop); UIHost.add(this.sheet); const isMobile = "ontouchstart" in window || window.innerWidth <= 768; if (!isMobile) { this.sheet.classList.add("desktop"); } this.backdrop.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.hide(); }; this.initTouchEvents(); } this.sheet.innerHTML = ""; this.sheet.appendChild(h("div", { className: "fc2-action-sheet-handle" })); const header = h( "div", { className: "fc2-action-sheet-header" }, h("div", { className: "fc2-action-sheet-title", id: "fc2-sheet-title" }, title) ); const closeBtn = h( "button", { className: "fc2-action-sheet-close-btn", "aria-label": t("btnClose"), onclick: (e) => { e.preventDefault(); this.hide(); } }, "×" ); header.appendChild(closeBtn); this.sheet.appendChild(header); const grid = h("div", { className: "fc2-action-sheet-grid" }); options.forEach((opt) => { const iconEl = typeof opt.icon === "string" ? UIUtils.icon(opt.icon) : opt.icon; const item = h( "a", { className: "fc2-action-sheet-item", href: opt.url, target: "_blank", rel: "noopener noreferrer", role: "button" }, iconEl, h("span", {}, opt.name) ); item.onclick = () => this.hide(); grid.appendChild(item); }); this.sheet.appendChild(grid); requestAnimationFrame(() => { this.backdrop.classList.add("active"); this.sheet.classList.add("active"); }); OverlayStack.push(this); } static initTouchEvents() { if (!this.sheet) return; this.sheet.addEventListener( "touchstart", (e) => { const touch = e.touches[0]; if (!touch) return; this.touchStartY = touch.clientY; if (this.sheet) this.sheet.style.transition = "none"; }, { passive: true } ); this.sheet.addEventListener( "touchmove", (e) => { const touch = e.touches[0]; if (!touch) return; this.touchCurrentY = touch.clientY; const deltaY = this.touchCurrentY - this.touchStartY; if (deltaY > 0 && this.sheet) { this.sheet.style.transform = `translateY(${deltaY}px)`; } }, { passive: true } ); this.sheet.addEventListener("touchend", () => { if (!this.sheet) return; this.sheet.style.transition = ""; const deltaY = this.touchCurrentY - this.touchStartY; if (deltaY > UI_CONSTANTS.SWIPE_DISMISS_THRESHOLD) { this.hide(); } else { this.sheet.style.transform = ""; } this.touchStartY = 0; this.touchCurrentY = 0; }); } static close() { this.hide(); } static hide() { if (!this.backdrop || !this.sheet) return; OverlayStack.remove(this); this.backdrop.classList.remove("active"); this.sheet.classList.remove("active"); const b = this.backdrop; const s = this.sheet; setTimeout(() => { if (s && s.parentElement) s.remove(); if (b && b.parentElement) b.remove(); if (this.backdrop === b) this.backdrop = null; if (this.sheet === s) this.sheet = null; }, TIMING.UI_TRANSITION_NORMAL); } }; _ActionSheet.backdrop = null; _ActionSheet.sheet = null; _ActionSheet.touchStartY = 0; _ActionSheet.touchCurrentY = 0; let ActionSheet = _ActionSheet; class PortalServiceImplementation { constructor() { this.cache = new Map(); this.PORTALS = [ { id: "dmm", name: "DMM", icon: IconPlayCircle, urlTemplate: EXTERNAL_URLS.DMM, shouldShow: ({ hostname }) => !hostname.includes("dmm.co.jp") }, { id: "fc2", name: "FC2", icon: IconLink, urlTemplate: EXTERNAL_URLS.FC2, shouldShow: ({ type, hostname }) => type === "fc2" && !hostname.includes("fc2.com") }, { id: "supjav", name: "Supjav", icon: IconBolt, urlTemplate: EXTERNAL_URLS.SUPJAV, shouldShow: ({ hostname }) => !hostname.includes("supjav") }, { id: "missav", name: "MissAV", icon: IconPlayCircle, urlTemplate: "", shouldShow: ({ hostname }) => !hostname.includes("missav") }, { id: "javdb", name: "JavDB", icon: IconDatabase, urlTemplate: EXTERNAL_URLS.JAVDB, shouldShow: ({ hostname }) => !hostname.includes("javdb") }, { id: "javbus", name: "JavBus", icon: IconDatabase, urlTemplate: EXTERNAL_URLS.JAVBUS, shouldShow: ({ hostname }) => !hostname.includes("javbus") }, { id: "javlibrary", name: "JavLibrary", icon: IconDatabase, urlTemplate: EXTERNAL_URLS.JAVLIBRARY, shouldShow: ({ hostname }) => !hostname.includes("javlibrary") }, { id: "fc2ppvdb", name: "FC2PPVDB", icon: IconListUl, urlTemplate: EXTERNAL_URLS.FC2PPVDB, shouldShow: ({ type, hostname }) => type === "fc2" && !hostname.includes("fc2ppvdb") }, { id: "fd2ppv", name: "FD2PPV", icon: IconServer, urlTemplate: EXTERNAL_URLS.FD2PPV, shouldShow: ({ type, hostname }) => type === "fc2" && !hostname.includes("fd2ppv") }, { id: "sukebei", name: "Sukebei", icon: IconMagnifyingGlass, urlTemplate: EXTERNAL_URLS.SUKEBEI, shouldShow: () => true } ]; } async onInit() { State.on("enabledPortals", () => this.clearCache()); } getAvailablePortals(data) { const hostname = location.hostname; const { id, type } = data; const cacheKey = `${id}-${type}-${hostname}`; const cached = this.cache.get(cacheKey); if (cached) return cached; const enabledPortals = State.proxy.enabledPortals || []; const results = this.PORTALS.filter( (portal) => enabledPortals.includes(portal.id) && portal.shouldShow({ id, type, hostname }) ).map((portal) => { let url = portal.urlTemplate; if (portal.id === "missav") { url = type === "fc2" ? EXTERNAL_URLS.MISSAV_FC2 : EXTERNAL_URLS.MISSAV; } return { id: portal.id, name: portal.name, icon: portal.icon, url: (url || "").replace("{id}", () => id) }; }); this.cache.set(cacheKey, results); return results; } getAllPortals() { return this.PORTALS.map((p) => ({ id: p.id, name: p.name })); } getAllSites() { return Array.from(new Set(this.PORTALS.map((p) => p.id))); } clearCache() { this.cache.clear(); } } const PortalService = AppContainer.register("portal-service", new PortalServiceImplementation()); const UIToolbar = { createDetailToolbar: (data, _markViewed, addPreviewButton) => { const { id, type, actress } = data; const C = Config.CLASSES; const toolbar = h("div", { className: "enh-toolbar" }); const infoArea = h("div", { className: C.infoArea }); const ctrls = h("div", { className: "card-top-right-controls" }); if (State.proxy.enableHistory) { const toggle = StatusToggle(id, "watched"); if (toggle && toggle.element) ctrls.appendChild(toggle.element); } const badge = Button$1( "", id, "javascript:void(0);", (e) => { e.preventDefault(); e.stopPropagation(); UIUtils.copyButtonBehavior(badge, id, t("tooltipCopied")); }, C.fc2IdBadge ); const btnText = badge.querySelector(`.${C.buttonText}`); if (btnText) btnText.textContent = id; ctrls.appendChild(badge); const links = h("div", { className: C.resourceLinksContainer }); if (State.proxy.enableHistory) { const wanted = StatusToggle(id, "wanted"); if (wanted && wanted.element) links.appendChild(wanted.element); } if (State.proxy.enableExternalLinks) { const trigger = Button$1( IconLink, t("labelExternalLinks"), "javascript:;", (e) => { e.preventDefault(); e.stopPropagation(); const portals = PortalService.getAvailablePortals({ id, type }); if (portals.length > 0) { ActionSheet.show(`${id} - ${t("labelExternalLinks")}`, portals); } } ); links.appendChild(trigger); } infoArea.appendChild(ctrls); if (actress) ActressButton(infoArea, actress); infoArea.appendChild(links); toolbar.appendChild(infoArea); ScraperService.fetchMagnets([{ id, type }], async (_, url) => { await Repository.cache.set(id, url); if (url) MagnetButton(links, url); }); if (type === "fc2") addPreviewButton(links, id); return toolbar; } }; const EnhancedCard = (data, markViewed, options = {}) => { const C = Config.CLASSES; const { id, type, title, primaryImageUrl, articleUrl, preservedIconsHTML, customClass } = data; const ctrls = h("div", { className: "card-top-right-controls", onclick: (e) => e.stopPropagation() }); const statusToggle = State.proxy.enableHistory ? StatusToggle(id, "watched") : null; const wantedToggle = State.proxy.enableHistory ? StatusToggle(id, "wanted", data) : null; if (statusToggle) ctrls.append(statusToggle.element); const badge = Button$1( "", id, "javascript:void(0);", (e) => { e.preventDefault(); e.stopPropagation(); UIUtils.copyButtonBehavior(badge, id, t("tooltipCopied")); badge.classList.add("pulse-once"); setTimeout(() => badge.classList.remove("pulse-once"), TIMING.UI_ANIMATION_BADGE); }, C.fc2IdBadge ); const btnText = badge.querySelector(`.${C.buttonText}`); if (btnText) btnText.textContent = id; ctrls.appendChild(badge); if (data.lastCheck) { const isRecent = Date.now() - data.lastCheck < 24 * 60 * 60 * 1e3; const healthIcon = h( "div", { className: "fc2-health-indicator", style: { marginLeft: "6px", fontSize: "10px", color: isRecent ? "var(--fc2-success)" : "var(--fc2-text-dim)", opacity: isRecent ? "1" : "0.5", display: "flex", alignItems: "center" }, title: isRecent ? "Recently verified healthy" : "Last checked: " + new Date(data.lastCheck).toLocaleDateString() }, isRecent ? "●" : "○" ); ctrls.appendChild(healthIcon); } const initialSrc = primaryImageUrl || data.imageUrl; const img = h("img", { src: initialSrc || UI_CONSTANTS.PLACEHOLDER_IMAGE, className: `${C.staticPreview} ${C.previewElement}`, decoding: "async", referrerPolicy: "no-referrer", onload: function() { this.parentElement?.classList.remove("fc2-skeleton"); this.parentElement?.classList.add("is-loaded"); this.classList.add("fc2-reveal-content"); }, onerror: function() { const src = this.src; if (src.includes("wumaobi.com") && src.endsWith("/cover.jpg")) { this.src = src.replace("/cover.jpg", "/main.jpg"); return; } if (src.includes("wumaobi.com") && src.endsWith("/main.jpg")) { this.src = EXTERNAL_URLS.FOURHOI_COVER.replace("{id}", id.padStart(7, "0")); return; } if (src.includes("fourhoi.com")) { if (data.fallbackImageUrl && src !== data.fallbackImageUrl) { this.src = data.fallbackImageUrl; return; } } if (src !== UI_CONSTANTS.PLACEHOLDER_IMAGE) { this.src = UI_CONSTANTS.PLACEHOLDER_IMAGE; } } }); const previewLink = h( "a", { className: `${C.videoPreviewContainer} fc2-skeleton`, href: articleUrl || "javascript:void(0);", target: "_blank" }, img ); const links = h("div", { className: C.resourceLinksContainer }); if (wantedToggle) links.appendChild(wantedToggle.element); if (State.proxy.enableExternalLinks) { const trigger = Button$1( IconLink, t("labelExternalLinks"), "javascript:;", (e) => { e.preventDefault(); e.stopPropagation(); const portals = PortalService.getAvailablePortals({ id, type }); if (portals.length > 0) { ActionSheet.show(t("labelExternalLinks"), portals); } else { Toast.show(t("alertNoExternalLinks"), "info"); } } ); links.appendChild(trigger); } const info = h( "div", { className: C.infoArea }, title ? h( "a", { className: C.customTitle, href: articleUrl || "javascript:;", target: "_blank", onclick: () => markViewed(id, card2) }, title ) : null, h("div", { className: "card-left-actions" }, links) ); const card2 = h( "div", { className: `${C.processedCard} ${type}-card ${customClass || ""} ${options.minimal ? "is-minimal" : ""}`, dataset: { id, type, previewSlug: data.previewSlug || "", enhSearching: "true" }, onclick: (e) => { if (!e.target.closest(`.${C.resourceBtn}`)) { markViewed(id, card2); } } }, previewLink, ctrls, info ); if (preservedIconsHTML?.includes(PATTERNS.CENSORED_INDICATOR)) card2.classList.add(C.isCensored); if (HistoryService.has(id)) card2.classList.add(C.isViewed); const initialState = ViewStore.get(id); if (!options.skipFilters) { UIUtils.applyCardVisibility(card2, initialState.hasMagnet); UIUtils.applyCensoredFilter(card2); UIUtils.applyHistoryVisibility(card2); } const component = { id, element: card2, update: (change) => { if (!change) return; const state = ViewStore.get(id); if (change.key === "isSearching") { if (change.value) { card2.dataset.enhSearching = "true"; } else { delete card2.dataset.enhSearching; if (!options.skipFilters) { UIUtils.applyCardVisibility(card2, state.hasMagnet); } } } if (change.key === "hasMagnet" || change.key === "magnetUrl") { if (state.hasMagnet && state.magnetUrl) { if (!links.querySelector(`.${C.btnMagnet}`)) { UIBuilder.addMagnetButton(links, state.magnetUrl); card2.classList.remove("no-magnet"); } } else if (change.key === "hasMagnet" && !change.value) { card2.classList.add("no-magnet"); } } if (change.key === "status") { const isViewed = change.value === "watched"; card2.classList.toggle(C.isViewed, isViewed); if (!options.skipFilters) { UIUtils.applyHistoryVisibility(card2); } statusToggle?.update?.(change); } }, destroy: () => { statusToggle?.destroy?.(); ComponentRegistry.unregister(component); } }; ComponentRegistry.register(component); return { finalElement: card2, linksContainer: links, newCard: card2, component }; }; const PreviewButton = async (cont, id, openGallery) => { if (!cont || !id || cont.querySelector(".btn-gallery")) return; const exists = await ScraperService.checkPreviewExists(id); if (!exists) return; const previewBtn = Button$1( IconImages, t("extraPreviewTitle"), "javascript:;", async (e) => { const btn = e.currentTarget; if (btn.classList.contains(Config.CLASSES.btnLoading)) return; btn.classList.add(Config.CLASSES.btnLoading); try { const res = await ScraperService.fetchExtraPreviews(id); if (res?.length) { openGallery(id, res); } else { Toast.show(t("alertNoPreview"), "info"); } } finally { btn.classList.remove(Config.CLASSES.btnLoading); } } ); previewBtn.classList.add("btn-gallery"); const magnetBtn = cont.querySelector(`.${Config.CLASSES.btnMagnet}`); if (magnetBtn) cont.insertBefore(previewBtn, magnetBtn); else cont.appendChild(previewBtn); }; const UIBuilder = { createElement: UIUtils.h, markViewed: (id, el) => UIUtils.markByStatus(id, "watched", el, UIUtils.applyHistoryVisibility), markByStatus: (id, status, el) => UIUtils.markByStatus(id, status, el, UIUtils.applyHistoryVisibility), btn: Button$1, createEnhancedCard: (data, options = {}) => EnhancedCard(data, UIBuilder.markViewed, options), createExtraPreviewsGrid: UIGallery.createExtraPreviewsGrid, openGallery: UIGallery.openGallery, addPreviewButton: (cont, id) => PreviewButton(cont, id, UIGallery.openGallery), addActressButton: ActressButton, toggleLoading: (cont, show) => UIUtils.toggleLoading(cont, show, Button$1), addMagnetButton: (cont, url) => { MagnetButton(cont, url); }, applyCardVisibility: UIUtils.applyCardVisibility, applyCensoredFilter: UIUtils.applyCensoredFilter, applyHistoryVisibility: UIUtils.applyHistoryVisibility, createDetailToolbar: (id, type, title, actress, previewSlug) => UIToolbar.createDetailToolbar( { id, type, title, actress, previewSlug }, UIBuilder.markViewed, UIBuilder.addPreviewButton ) }; const log$i = Logger.scope("Site"); class BaseSite { constructor(config) { this.observers = []; this.activeContext = PageContext.Unknown; this._unsubs = []; this.config = config; } async init() { try { log$i.debug(`Initializing ${this.config.name}`); this._unsubs.forEach((fn) => fn()); this._unsubs = []; if (this.config.onBeforeInit) await this.config.onBeforeInit(); this._unsubs.push( CoreEvents.on(AppEvents.HISTORY_LOADED, () => { const cards = document.querySelectorAll(`.${Config.CLASSES.processedCard}`); if (cards.length > 0) { log$i.info(`History loaded, refreshing ${cards.length} cards`); cards.forEach((c) => { const id = c.dataset.id; if (!id) return; const status = HistoryService.getStatus(id); if (status) { UIBuilder.markByStatus(id, status, c); } }); } }) ); this.activeContext = this.detectContext(); if (this.config.list) this.initListMode(); if (this.config.detail && this.activeContext === PageContext.Detail) { this.initDetailMode(); } if (this.config.customInit) this.config.customInit(); if (this.config.onInit) await this.config.onInit(); if (this.config.onAfterInit) await this.config.onAfterInit(); this._unsubs.push( State.on(({ prop }) => { if (["hideNoMagnet", "enableMagnets", "hideCensored", "hideViewed", "hideBlocked"].includes( prop )) { this.refreshVisibility(); } }) ); } catch (error) { log$i.error(`Init failed for ${this.config.name}`, error); } } refreshVisibility() { const enableMagnets = State.proxy.enableMagnets; const cards = document.querySelectorAll(`.${Config.CLASSES.cardRebuilt}`); cards.forEach((c) => { const card2 = c; const hasMagnetBtn = !!card2.querySelector(`.${Config.CLASSES.btnMagnet}`); const effectiveHasMagnet = !enableMagnets ? true : hasMagnetBtn; UIBuilder.applyCardVisibility(card2, effectiveHasMagnet); UIBuilder.applyCensoredFilter(card2); UIBuilder.applyHistoryVisibility(card2); }); } initListMode() { if (!this.config.list) return; const list = this.config.list; const process = (nodes) => { if (nodes.length > 0) Logger.debug("Site", `Found ${nodes.length} potential cards via ${list.cardSelector}`); nodes.forEach((c) => { try { if (list.containerSelector && !c.closest(list.containerSelector)) { return; } if (!c.classList.contains(Config.CLASSES.cardRebuilt) && !c.hasAttribute("data-enh-rebuilding")) { c.setAttribute("data-enh-rebuilding", "true"); this.processCard(c).catch((err) => { log$i.error("processCard failed", err); c.removeAttribute("data-enh-rebuilding"); }); } } catch (e) { log$i.error("Card iteration error", e); } }); }; const initialNodes = Array.from(document.querySelectorAll(list.cardSelector)); if (initialNodes.length > 0) process(initialNodes); const obs = new MutationObserver((muts) => { const added = []; for (const m of muts) { for (const n of Array.from(m.addedNodes)) { if (n.nodeType !== 1) continue; const el = n; if (el.matches(list.cardSelector)) added.push(el); else { const children = el.querySelectorAll(list.cardSelector); if (children.length > 0) added.push(...Array.from(children)); } } } if (added.length) process(added); }); obs.observe(document.body, { childList: true, subtree: true }); this.observers.push(obs); } initDetailMode() { if (!this.config.detail) return; const detail = this.config.detail; const check = () => { const target = document.querySelector(detail.triggerSelector || detail.mainImageSelector || ""); if (target && !target.hasAttribute("data-enh-processed")) { target.setAttribute("data-enh-processed", "true"); if (detail.customDetailAction) { Promise.resolve(detail.customDetailAction(target, obs)).catch((err) => { log$i.error("Detail action failed", err); target.removeAttribute("data-enh-processed"); }); } } }; const obs = new MutationObserver(check); check(); obs.observe(document.body, { childList: true, subtree: true }); this.observers.push(obs); } async processCard(card2) { if (!this.config.list) return; const list = this.config.list; try { let data = list.extractor(card2); if (data instanceof Promise) data = await data; if (!data) { Logger.trace("Site", "Extractor returned null", card2); card2.removeAttribute("data-enh-rebuilding"); return; } card2.setAttribute("data-enh-processed", "true"); const extraUi = list.getExtraUi ? list.getExtraUi(card2) : {}; const { finalElement, newCard } = UIBuilder.createEnhancedCard({ ...data, ...extraUi }); if (list.postProcess) list.postProcess(card2, finalElement, newCard, data); card2.replaceChildren(finalElement); card2.classList.add(Config.CLASSES.cardRebuilt); card2.dataset.id = data.id; card2.removeAttribute("data-enh-rebuilding"); if (card2.parentElement && !card2.parentElement.hasAttribute("data-enh-grid-container")) { card2.parentElement.setAttribute("data-enh-grid-container", "true"); } if (newCard.classList.contains(Config.CLASSES.isCensored)) card2.classList.add(Config.CLASSES.isCensored); if (newCard.classList.contains(Config.CLASSES.isViewed)) card2.classList.add(Config.CLASSES.isViewed); UIBuilder.applyCensoredFilter(card2); UIBuilder.applyHistoryVisibility(card2); CoreEvents.emit(AppEvents.CARD_READY, { id: data.id, type: data.type, el: card2 }); } catch (error) { log$i.error("processCard failed", error); } } cleanup() { this.observers.forEach((o) => o.disconnect()); this.observers = []; if (this.config.onCleanup) this.config.onCleanup(); } } const fc2ppvdb = { name: "FC2PPVDB", hostnames: ["fc2ppvdb.com"], detectContext: () => { const path = location.pathname; if (/^\/articles\/\d+/.test(path)) return PageContext.Detail; if (/^\/actresses\/\d+/.test(path)) return PageContext.Search; return PageContext.List; }, list: { containerSelector: "#actress-articles .flex.flex-wrap:not(.flex-end):not(.flex-between), .container .flex.flex-wrap:not(.flex-end):not(.flex-between), .max-w-screen-xl .flex.flex-wrap:not(.flex-end):not(.flex-between), main .flex.flex-wrap:not(.flex-end):not(.flex-between)", cardSelector: ".flex.flex-wrap > div:not(.card-rebuilt)", extractor: (card2) => { const videoInfo = MediaUtils.parseVideoId(card2.innerText, card2.querySelector("a")?.href); if (!videoInfo?.id) return null; const id = videoInfo.id; const isActressPage = location.pathname.includes("/actresses/"); const globalActress = isActressPage ? MediaUtils.cleanActressName(document.querySelector(".sm\\:w-11\\/12.text-white")?.textContent) : ""; const img = card2.querySelector("img:not(.hidden)") || card2.querySelector("img"); const imageUrl = getBestImageSource(img); const writer = card2.querySelector('a[href^="/writers/"]')?.textContent?.trim(); const actress = globalActress || writer; return { id, type: "fc2", title: (card2.querySelector("a.title-font, div.mt-1 a.text-white, h2 a")?.textContent || card2.querySelector('a[href*="/articles/"][title]')?.getAttribute("title") || `FC2-PPV-${id}`).trim(), primaryImageUrl: State.proxy.replaceFc2Covers ? EXTERNAL_URLS.WUMAOBI_COVER.replace("{id}", id.padStart(7, "0")) : imageUrl, imageUrl, fallbackImageUrl: imageUrl, articleUrl: `/articles/${id}`, actress: actress ?? void 0, previewSlug: `fc2-ppv-${id}` }; }, getExtraUi: (card2) => ({ preservedIconsHTML: Array.from( card2.querySelectorAll(".float .icon, .badges span, span.absolute.top-0.right-0") ).map((n) => { const clone = n.cloneNode(true); if (clone.classList.contains("absolute")) { clone.style.position = "relative"; clone.style.display = "inline-flex"; clone.style.alignItems = "center"; clone.style.marginRight = "8px"; } return clone.outerHTML; }).join("") }) }, detail: { mainImageSelector: "div.lg\\:w-2\\/5", customDetailAction: async (cont) => { const pid = MediaUtils.extractFC2Id(document.querySelector(".work-title")?.textContent || ""); const id = pid || MediaUtils.extractFC2Id(location.href) || ""; if (!id) return; const title = (document.querySelector("#article-info h2")?.textContent || document.title.split("-")[0] || `FC2-PPV-${id}`).trim(); const actress = MediaUtils.cleanActressName(document.querySelector(".actress-name")?.textContent); if (actress) Repository.cache.set(`actress_${id}`, actress); const img = cont.querySelector("img:not(.hidden)") || cont.querySelector("img"); const primaryImageUrl = State.proxy.replaceFc2Covers ? EXTERNAL_URLS.WUMAOBI_COVER.replace("{id}", id.padStart(7, "0")) : getBestImageSource(img) || void 0; const { finalElement, linksContainer } = UIBuilder.createEnhancedCard({ id, type: "fc2", title, actress: actress ?? void 0, primaryImageUrl, fallbackImageUrl: getBestImageSource(img) || void 0, articleUrl: location.href }); finalElement.classList.add("is-detail"); cont.style.padding = "0"; cont.style.lineHeight = "0"; cont.style.background = "transparent"; cont.style.height = "auto"; cont.replaceChildren(finalElement); ScraperService.fetchMagnets([{ id, type: "fc2" }], async (_, url) => { await Repository.cache.set(id, url); if (url) UIBuilder.addMagnetButton(linksContainer, url); }); UIBuilder.addPreviewButton(linksContainer, id); } } }; const fd2ppv = { name: "FD2PPV", hostnames: ["fd2ppv.cc"], detectContext: () => { ScraperService.resetFD2Backoff(); const path = window.location.pathname; const segments = path.split("/").filter(Boolean); if (segments[0] === "articles" && segments.length >= 2 && segments[1] && /^\d+$/.test(segments[1])) { return PageContext.Detail; } return PageContext.List; }, list: { containerSelector: ".artist-list, .work-list, .flex.flex-wrap:not(.flex-end):not(.flex-between), .container .grid, .other-works-grid", cardSelector: ".artist-card:not(.card-rebuilt)", extractor: (card2) => { const link = card2.querySelector('a[href*="/articles/"]'); const id = MediaUtils.extractFC2Id(link?.href || ""); const img = card2.querySelector("img.other-work-image") || card2.querySelector("img"); const actressImg = card2.querySelector(".artist-avatar"); const globalActress = MediaUtils.cleanActressName( document.querySelector(".artist-detail-name")?.textContent ); const actress = MediaUtils.cleanActressName(actressImg?.getAttribute("alt") || actressImg?.title) || globalActress; return id ? { id, type: "fc2", title: (card2.querySelector(".other-work-title a")?.textContent || card2.querySelector("p a")?.textContent || card2.querySelector("p")?.textContent || card2.querySelector("h3 a")?.textContent || `FC2-PPV-${id}`).trim(), actress: actress ?? void 0, primaryImageUrl: getBestImageSource(img), articleUrl: link?.href || `/articles/${id}` } : null; }, postProcess: (card2, _el, newCard) => { const stats = card2.querySelector(".stats"); if (stats) { const infoArea = newCard.querySelector(`.${Config.CLASSES.infoArea}`); if (infoArea) { infoArea.insertBefore(stats.cloneNode(true), infoArea.querySelector(".card-left-actions")); } } }, getExtraUi: (card2) => { const preservedIconsHTML = Array.from(card2.querySelectorAll(".float i, .float .icon")).map((n) => n.outerHTML).join(""); return { preservedIconsHTML }; } }, detail: { mainImageSelector: ".work-image-large", customDetailAction: async (cont) => { const id = MediaUtils.extractFC2Id(location.href) || MediaUtils.extractFC2Id(document.querySelector(".work-title")?.textContent || "") || MediaUtils.parseVideoId( document.querySelector('.work-meta-value a[href*="article"]')?.getAttribute("href") || "" )?.id; if (!id) return; const title = (document.querySelector(".work-brief")?.textContent || document.querySelector(".work-title")?.textContent || document.title.split("|")[0] || `FC2-PPV-${id}`).replace(/\d{7,8}/, "").trim() || `FC2-PPV-${id}`; const actressRaw = document.querySelector(".artist-info-card .artist-name a")?.textContent || document.querySelector(".artist-name a")?.textContent || Array.from(document.querySelectorAll(".work-meta-label")).find((el) => el.textContent?.trim() === "賣家")?.nextElementSibling?.querySelector("a")?.textContent; const actress = MediaUtils.cleanActressName(actressRaw); if (actress) Repository.cache.set(`actress_${id}`, actress); const img = cont.querySelector("img"); const primaryImageUrl = getBestImageSource(img) || void 0; const { finalElement, linksContainer } = UIBuilder.createEnhancedCard({ id, type: "fc2", title, actress: actress ?? void 0, primaryImageUrl, articleUrl: location.href }); finalElement.classList.add("is-detail"); cont.style.padding = "0"; cont.style.lineHeight = "0"; cont.style.background = "transparent"; cont.style.height = "auto"; cont.replaceChildren(finalElement); ScraperService.fetchMagnets([{ id, type: "fc2" }], async (_, url) => { await Repository.cache.set(id, url); if (url) UIBuilder.addMagnetButton(linksContainer, url); }); UIBuilder.addPreviewButton(linksContainer, id); } } }; const supjav = { name: "Supjav", hostnames: ["supjav.com"], detectContext: () => { const path = window.location.pathname; if (path.includes("/search/") || window.location.search.includes("s=")) return PageContext.Search; const cleanPath = path.replace(/^\/(zh|en|ja)\//, "/"); const segments = cleanPath.split("/").filter(Boolean); if (segments.length === 1 && segments[0] && /^\d+\.html$/.test(segments[0])) return PageContext.Detail; if (segments.length === 1 && segments[0]?.toLowerCase() !== "new" && !["popular", "maker", "cast", "tag", "category"].includes(segments[0]?.toLowerCase() || "")) { return PageContext.Detail; } return PageContext.List; }, list: { containerSelector: ".posts.clearfix:not(:has(.swiper-wrapper)), .posts:not(:has(.swiper-wrapper))", cardSelector: ".post:not(.card-rebuilt)", extractor: (card2) => { const tLink = card2.querySelector('h3 a, a[rel="bookmark"]'); const img = card2.querySelector("img.thumb, img"); const text = tLink?.title || tLink?.textContent || img?.alt || ""; const info = MediaUtils.parseVideoId(text, tLink?.href || ""); if (!info) return null; return { ...info, title: text.trim(), primaryImageUrl: MediaUtils.cleanImageUrl( card2.querySelector("img")?.getAttribute("data-original") || getBestImageSource(img) || "" ), articleUrl: tLink?.href, previewSlug: info.previewSlug || null }; }, postProcess: (card2, _el, newCard, data) => { const C = Config.CLASSES; if (data.title?.includes("[有]") || card2.innerText.includes("有码")) { card2.classList.add(C.isCensored); newCard.classList.add(C.isCensored); } const meta = card2.querySelector(".meta"); if (meta) { const infoArea = newCard.querySelector(`.${C.infoArea}`); if (infoArea) infoArea.insertBefore(meta.cloneNode(true), infoArea.querySelector(".card-left-actions")); } } }, detail: { triggerSelector: ".archive-title h1, h1.entry-title, .post-title h1", customDetailAction: async (titleEl) => { await new Promise((r) => setTimeout(r, TIMING.SCRIPT_INJECTION_DELAY)); try { const video = document.querySelector("#dz_video, .video-container, .entry-content .video-player"); const info = MediaUtils.parseVideoId(titleEl.textContent || "", location.href); if (!info) return; const actress = MediaUtils.cleanActressName( document.querySelector(".post-meta a")?.textContent ); const toolbar = UIBuilder.createDetailToolbar( info.id, info.type, titleEl.textContent?.trim() || "", actress ?? void 0, info.previewSlug ?? void 0 ); if (video) { video.after(toolbar); } else { titleEl.after(toolbar); } if (info.type === "fc2") { const fetchedActress = await ScraperService.fetchActressFromFD2(info.id); if (fetchedActress) { const infoArea = toolbar.querySelector(`.${Config.CLASSES.infoArea}`); if (infoArea) UIBuilder.addActressButton(infoArea, fetchedActress); } } } catch (err) { Logger.warn("Supjav", "Detail injection failed", err); } } } }; const missav = { name: "MissAV", hostnames: ["missav.ws", "missav.ai"], detectContext: () => { const path = window.location.pathname; const segments = path.split("/").filter(Boolean); const lastSegment = (segments[segments.length - 1] || "").toLowerCase(); const isDetailPattern = /^(fc2-ppv-|[a-z]{2,10}-)\d+/i.test(lastSegment) || /^[a-z0-9]{15,}$/.test(lastSegment); if (isDetailPattern && segments.length <= 3) { return PageContext.Detail; } const listBlacklist = ["search", "new", "actress", "maker", "dm", "genres", "series", "tags", "makers"]; if (segments.some((s) => listBlacklist.some((b) => s.toLowerCase().startsWith(b)))) return PageContext.List; if (segments.length === 1 && !listBlacklist.includes(lastSegment)) { return PageContext.Detail; } return path.includes("/search/") ? PageContext.Search : PageContext.List; }, list: { containerSelector: "main, .grid, .sm\\:container, div.posts, #main", cardSelector: 'div.grid[class*="grid-cols-"] > div:not(.card-rebuilt), div.thumbnail:not(.card-rebuilt)', extractor: (card2) => { if (card2.closest('[x-for*="recommend"]') || card2.closest('[x-for*="trending"]') || card2.querySelector('[x-text*="item."]') || card2.hasAttribute("@mouseenter") || card2.hasAttribute("x-show")) { return null; } const tLink = card2.querySelector( [ "a.text-secondary", "a.hover\\:text-primary", "div.my-2 a", "div.mt-1 a", ".video-title a", ".thumbnail + div a" ].join(",") ); const img = card2.querySelector("img"); const video = card2.querySelector("video"); const anyLink = tLink || card2.querySelector( 'a[href*="/fc2-ppv-"], a[href*="/en/"], a[href*="/ja/"], a[href*="/cn/"]' ); if (!anyLink || !anyLink.getAttribute("href") || anyLink.getAttribute("href") === "#") return null; const info = MediaUtils.parseVideoId(anyLink.textContent || "", anyLink.href || ""); if (!info) return null; if (info.id.length < 5 && info.type === "fc2") return null; const previewSlug = video?.dataset.src?.match(/fourhoi\.com\/([^/]+)\/preview\.mp4/)?.[1] || video?.src?.match(/fourhoi\.com\/([^/]+)\/preview\.mp4/)?.[1] || info.previewSlug || null; return { ...info, title: anyLink.textContent?.trim() || "", imageUrl: getBestImageSource(img), articleUrl: anyLink.href, previewSlug }; }, postProcess: (card2) => { card2.removeAttribute("x-data"); card2.addEventListener("mouseenter", (e) => e.stopPropagation(), true); } }, detail: { triggerSelector: 'div[x-data*="player"], div.player-container, h1.text-base, div.mt-4', customDetailAction: async (el, obs) => { const title = document.querySelector("h1.text-base")?.textContent || document.title; const info = MediaUtils.parseVideoId(title, location.href); if (!info) return; let actress = null; if (info.type === "jav") { const contentArea = document.querySelector("div.mt-4, div.text-secondary, main"); const actresses = Array.from((contentArea || document).querySelectorAll('a[href*="/actresses/"]')).map( (a) => MediaUtils.cleanActressName(a.textContent) ); actress = actresses.find((a) => !!a) || null; } const toolbar = UIBuilder.createDetailToolbar( info.id, info.type, title, actress ?? void 0, info.previewSlug ?? void 0 ); const target = document.querySelector("h1.text-base") || el; target.insertAdjacentElement("afterend", toolbar); if (info.type === "fc2") { const fetchedActress = await ScraperService.fetchActressFromFD2(info.id); if (fetchedActress) { const infoArea = toolbar.querySelector(`.${Config.CLASSES.infoArea}`); if (infoArea) UIBuilder.addActressButton(infoArea, fetchedActress); } } obs?.disconnect(); } } }; const javdb = { name: "JavDB", hostnames: ["javdb.com", "javdb565.com"], detectContext: () => { const path = location.pathname; if (path.includes("/search/") || location.search.includes("q=")) return PageContext.Search; if (path.startsWith("/v/")) return PageContext.Detail; return PageContext.List; }, list: { containerSelector: ".movie-list, .tile-images:not(.preview-images)", cardSelector: ".item:not(.card-rebuilt), .tile-item:not(.card-rebuilt)", extractor: (card2) => { const link = card2.matches('a.box, a.tile-item, a[href^="/v/"]') ? card2 : card2.querySelector('a.box, a.tile-item, a[href^="/v/"]'); if (!link) return null; const idStrong = card2.querySelector(".video-title strong, .video-number"); const text = idStrong ? idStrong.textContent : link.title || link.innerText; const img = card2.querySelector("img"); const info = MediaUtils.parseVideoId(text || "", link.href); return info ? { ...info, title: (card2.querySelector(".video-title")?.textContent || link.title || text || "").trim(), imageUrl: getBestImageSource(img), articleUrl: link.href, previewSlug: info.previewSlug || null } : null; }, postProcess: (card2, _el, newCard, _data) => { const score = card2.querySelector(".score"); const meta = card2.querySelector(".meta"); const infoArea = newCard.querySelector(`.${Config.CLASSES.infoArea}`); if (infoArea) { if (score) infoArea.insertBefore(score.cloneNode(true), infoArea.querySelector(".card-left-actions")); if (meta) infoArea.insertBefore(meta.cloneNode(true), infoArea.querySelector(".card-left-actions")); } } }, detail: { mainImageSelector: ".column-video-cover", customDetailAction: async (cont) => { const titleEl = document.querySelector("h2.title"); const info = MediaUtils.parseVideoId(titleEl?.textContent || "", location.href); if (info) { const img = cont.querySelector("img.video-cover"); const actress = MediaUtils.cleanActressName(cont.querySelector(".meta")?.textContent); const { finalElement, linksContainer } = UIBuilder.createEnhancedCard({ id: info.id, type: info.type, title: titleEl?.textContent?.trim() || "", primaryImageUrl: img?.src || "", articleUrl: location.href, previewSlug: info.previewSlug ?? void 0 }); finalElement.classList.add("is-detail"); Array.from(cont.children).forEach((child) => { if (child instanceof HTMLElement) child.style.display = "none"; }); cont.appendChild(finalElement); cont.style.maxWidth = "100%"; const magnetLinks = Array.from( document.querySelectorAll('#magnets-content a[href^="magnet:?"]') ); const firstLink = magnetLinks[0]; if (magnetLinks.length > 0 && firstLink) { Repository.cache.set(info.id, firstLink.href); magnetLinks.slice(0, 3).forEach((m) => UIBuilder.addMagnetButton(linksContainer, m.href)); } else { ScraperService.fetchMagnets([{ id: info.id, type: info.type }], async (_, url) => { await Repository.cache.set(info.id, url); if (url && linksContainer) UIBuilder.addMagnetButton(linksContainer, url); }); } if (info.type === "fc2") { UIBuilder.addPreviewButton(linksContainer, info.id); if (!actress) { const fetchedActress = await ScraperService.fetchActressFromFD2(info.id); if (fetchedActress) { const infoArea = finalElement.querySelector(`.${Config.CLASSES.infoArea}`); if (infoArea) UIBuilder.addActressButton(infoArea, fetchedActress); } } } const bindNativeButton = (selector) => { const btn = document.querySelector(selector); if (btn && !btn.hasAttribute("data-hooked")) { btn.setAttribute("data-hooked", "true"); btn.addEventListener("click", () => { HistoryService.add(info.id); }); } }; bindNativeButton('form.button_to[action*="/reviews/watched"] button'); bindNativeButton("button.js-watched-video"); } } } }; const sukebei = { name: "Sukebei", hostnames: ["sukebei.nyaa.si"], detectContext: () => { return PageContext.List; }, list: { containerSelector: "table.torrent-list tbody", cardSelector: "tr", extractor: () => null }, detail: { customDetailAction: async () => { } } }; const ocili = { name: "0cili", hostnames: ["0cili.org", "0cili.cc"], detectContext: () => { return PageContext.List; }, list: { containerSelector: "table.torrent-list tbody", cardSelector: "tr", extractor: () => null }, detail: { customDetailAction: async () => { } } }; const SiteRegistry = { fc2ppvdb, fd2ppv, supjav, missav, javdb, sukebei, ocili }; const SiteConfigs = SiteRegistry; const log$h = Logger.scope("SiteManager"); class GenericSite extends BaseSite { detectContext() { if (this.config.detectContext) { try { return this.config.detectContext(); } catch (e) { log$h.error("Custom detectContext failed", e); } } const path = window.location.pathname; if (path.includes("/search/") || window.location.search.includes("keyword=")) return PageContext.Search; if (path.includes("/v/") || path.includes("/detail/") || path.includes("/movie/") || path.endsWith(".html")) return PageContext.Detail; return PageContext.List; } } class SiteServiceImplementation { constructor() { this.registry = new Map(); this.activeSite = null; this.currentUrl = location.href; } async onBootstrap() { log$h.debug("Bootstrapped, initiating site matching"); this.registerAll(SiteConfigs); await this.bootstrap(); } registerAll(configs) { Object.entries(configs).forEach(([name, config]) => { config.name = name; this.registry.set(name, config); }); } async bootstrap() { const hostname = location.hostname; let matchedConfig = null; for (const config of this.registry.values()) { const matches = config.hostnames.some( (hn) => typeof hn === "string" ? hostname.includes(hn) : hn.test(hostname) ); if (matches) { matchedConfig = config; break; } } if (matchedConfig) { log$h.info(`Matched site: ${matchedConfig.name}`); this.activeSite = new GenericSite(matchedConfig); await this.activeSite.init(); this.initUrlWatcher(); CoreEvents.emit(AppEvents.SITE_READY, { siteName: matchedConfig.name }); } else { log$h.warn(`No site config matched for ${hostname}`); } } initUrlWatcher() { const check = () => { if (location.href !== this.currentUrl) { this.currentUrl = location.href; log$h.debug("URL change detected"); this.activeSite?.init(); } }; const origPushState = history.pushState.bind(history); const origReplaceState = history.replaceState.bind(history); history.pushState = function(...args) { const res = origPushState(...args); check(); return res; }; history.replaceState = function(...args) { const res = origReplaceState(...args); check(); return res; }; window.addEventListener("popstate", check); } getActiveSite() { return this.activeSite; } } AppContainer.register("site-service", new SiteServiceImplementation()); const log$g = Logger.scope("Magnet"); const CACHE_NO_MAGNET = "@@NO_MAGNET@@"; class MagnetServiceImplementation { constructor() { this.queue = new Map(); this.activeSearches = new Set(); this.maxConcurrency = MAGNET_CONFIG.MAX_CONCURRENCY; this.onProgress = null; this.flushTimer = null; } onBootstrap() { log$g.debug("Subscribed to UI_READY"); CoreEvents.on(AppEvents.UI_READY, () => { log$g.info("Manager active"); }); } async fetchMagnet(id, type) { const cached = await Repository.cache.get(id); if (cached) { if (cached === CACHE_NO_MAGNET) return null; return cached; } if (this.queue.has(id)) { const task = this.queue.get(id); return new Promise((resolve) => { const originalResolve = task.resolve; task.resolve = (url) => { originalResolve(url); resolve(url); }; }); } return new Promise((resolve) => { const task = { id, type: type || MAGNET_CONFIG.DEFAULT_TYPE, resolve, retryCount: 0, status: "pending", startTime: Date.now() }; this.queue.set(id, task); setTimeout(() => { const t2 = this.queue.get(id); if (t2 === task && t2.status !== "found" && t2.status !== "failed") { this._onTimeout(t2); this.queue.delete(id); } }, MAGNET_CONFIG.SEARCH_TIMEOUT_MS); this._requestProcess(); }); } _onTimeout(task) { if (task.status === "found" || task.status === "failed") return; log$g.warn(`Search timed out for ${task.id}`); task.status = "failed"; this._notifyUI(task.id, "failed"); task.resolve(null); } _requestProcess() { if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = setTimeout(() => { this._processQueue(); this.flushTimer = null; }, 100); } async _processQueue() { if (this.activeSearches.size >= this.maxConcurrency) return; const pending = Array.from(this.queue.values()).filter((t2) => t2.status === "pending").sort((a, b) => a.startTime - b.startTime); if (pending.length === 0) return; const batchSize = NETWORK.CHUNK_SIZE; const batch = pending.slice(0, batchSize); batch.forEach((task) => { task.status = "searching"; this.activeSearches.add(task.id); }); this._updateStatus(); try { await this._executeSearchBatch(batch); } catch (error) { log$g.error("Batch search error", error); } finally { batch.forEach((task) => this.activeSearches.delete(task.id)); this._requestProcess(); } } async _executeSearchBatch(batch) { const ids = batch.map((t2) => t2.id); log$g.debug(`Searching batch of ${ids.length} items`); await ScraperService.fetchMagnets( batch.map((t2) => ({ id: t2.id, type: t2.type })), (id, url) => { const task = this.queue.get(id); if (!task) return; if (url) { this._onTaskSuccess(task, url); } else { this._onTaskFailed(task, true); } } ); } async _onTaskSuccess(task, url) { await Repository.cache.set(task.id, url); task.status = "found"; task.resolve(url); this.queue.delete(task.id); this._updateStatus(); this._notifyUI(task.id, "found", url); } async _onTaskFailed(task, forceFail = false) { const maxRetries = MAGNET_CONFIG.MAX_RETRIES; if (!forceFail && task.retryCount < maxRetries) { task.retryCount++; task.status = "pending"; task.startTime = Date.now() + Math.pow(2, task.retryCount) * MAGNET_CONFIG.RETRY_DELAY; log$g.debug(`Retrying ${task.id} (${task.retryCount}/${maxRetries})`); } else { task.status = "failed"; task.resolve(null); this.queue.delete(task.id); this._notifyUI(task.id, "failed"); await Repository.cache.set(task.id, CACHE_NO_MAGNET); } this._updateStatus(); } _notifyUI(id, status, url) { if (status === "found" && url) { CoreEvents.emit(AppEvents.MAGNET_FOUND, { id, url }); } else if (status === "failed") { CoreEvents.emit(AppEvents.MAGNET_FAILED, { id }); } } _updateStatus() { if (!this.onProgress) return; const all = Array.from(this.queue.values()); this.onProgress({ total: this.queue.size, active: this.activeSearches.size, found: 0, failed: all.filter((t2) => t2.status === "failed").length }); } predictiveSearch(card2) { const { id, type } = card2.dataset; if (id && type && !this.queue.has(id)) { if (this.queue.size < MAGNET_CONFIG.PREDICTIVE_LIMIT) { this.fetchMagnet(id, type); } } } } const MagnetService = AppContainer.register("magnet-service", new MagnetServiceImplementation()); const SmartTooltips = { currentTooltip: null, hideTimeout: null, init() { document.addEventListener( "mouseover", (e) => { const target = e.target; if (!(target instanceof Element)) return; const tooltipText = target.getAttribute("data-tooltip") || target.title; if (tooltipText && this.shouldShowTooltip(target)) { this.show(target, tooltipText); target._hasTooltip = true; } }, true ); document.addEventListener( "mouseout", (e) => { const target = e.target; if (!(target instanceof Element)) return; if (target.hasAttribute("data-tooltip") || target.title) { this.hide(); } }, true ); }, shouldShowTooltip(element) { if ("ontouchstart" in window) return false; if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") return false; return true; }, show(element, text) { this.hide(); const tooltip = h( "div", { className: "smart-tooltip", style: ` position: fixed; background: rgba(0, 0, 0, 0.9); color: ${UI_TOKENS.COLORS.WHITE}; padding: ${UI_TOKENS.SPACING.XS} ${UI_TOKENS.SPACING.MD}; border-radius: ${UI_TOKENS.RADIUS.MD}; font-size: 12px; z-index: ${UI_CONSTANTS.Z_INDEX_TOOLTIP}; pointer-events: none; white-space: nowrap; max-width: 300px; backdrop-filter: blur(${UI_TOKENS.BACKDROP.BLUR}); box-shadow: ${UI_TOKENS.BACKDROP.SHADOW}; opacity: 0; will-change: transform, opacity; transition: opacity 0.2s, transform 0.2s; transform: translateY(5px); ` }, text ); UIHost.add(tooltip); const rect = element.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); let top = rect.bottom + 8; let left = rect.left + rect.width / 2 - tooltipRect.width / 2; if (left < 8) left = 8; if (left + tooltipRect.width > window.innerWidth - 8) { left = window.innerWidth - tooltipRect.width - 8; } if (top + tooltipRect.height > window.innerHeight - 8) { top = rect.top - tooltipRect.height - 8; } tooltip.style.top = `${top}px`; tooltip.style.left = `${left}px`; requestAnimationFrame(() => { tooltip.style.opacity = "1"; }); this.currentTooltip = tooltip; }, hide() { if (this.currentTooltip) { this.currentTooltip.style.opacity = "0"; setTimeout(() => { if (this.currentTooltip) { this.currentTooltip.remove(); this.currentTooltip = null; } }, 200); } } }; const GlobalClick = { init() { document.addEventListener("click", (e) => { const target = e.target; if (!target.closest(".enh-dropdown")) { document.querySelectorAll(".enh-dropdown.active").forEach((d) => { d.classList.remove("active"); const card2 = d.closest(".processed-card") || d.closest(".card-rebuilt"); if (card2) card2.classList.remove("has-active-dropdown"); }); } }); } }; const log$f = Logger.scope("UI"); class UIEnhancementService { constructor() { this.observer = null; } onInit() { log$f.debug("Initializing UI interactions"); SmartTooltips.init(); GlobalClick.init(); this.initObserver(); this.bindStateListeners(); this.bindEventListeners(); this.updateGlobalClasses(); this.updateVisibilityForCards(); } initObserver() { this.observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { this.processVisibleCard(entry.target); this.observer?.unobserve(entry.target); } }); }, { rootMargin: "200px" } ); } processVisibleCard(cardEl) { const C = Config.CLASSES; if (State.proxy.enableHistory && cardEl.dataset.id) { const isViewed = UIUtils.hasHistory(cardEl.dataset.id); cardEl.classList.toggle(C.isViewed, isViewed); } UIUtils.applyHistoryVisibility(cardEl); UIUtils.applyCollectionFilter(cardEl); const hasMagnet = !!cardEl.querySelector(`.${C.btnMagnet}`) || cardEl.dataset.hasMagnet === "true"; UIUtils.applyCardVisibility(cardEl, hasMagnet); UIUtils.applyCensoredFilter(cardEl); } bindStateListeners() { State.on("showViewedBtn", () => this.updateGlobalClasses()); State.on("showIdBadge", () => this.updateGlobalClasses()); const filterProps = [ "hideViewed", "hideNoMagnet", "hideCensored", "hideBlocked", "hideUnwanted" ]; filterProps.forEach((prop) => { State.on(prop, () => this.updateVisibilityForCards()); }); } bindEventListeners() { CoreEvents.on(AppEvents.CARD_READY, async ({ id, type, el }) => { const C = Config.CLASSES; const container2 = el.querySelector(`.${C.resourceLinksContainer}`); if (!container2) return; ViewStore.set(id, "isSearching", true); el.dataset.enhSearching = "true"; if (type === "fc2" && State.proxy.loadExtraPreviews) { UIBuilder.addPreviewButton(container2, id); } if (State.proxy.enableMagnets) { try { const cached = await Repository.cache.get(id); if (cached) { CoreEvents.emit(AppEvents.MAGNET_FOUND, { id, url: cached }); return; } MagnetService.fetchMagnet(id, type).catch((err) => { log$f.error(`Fetch magnet failed for ${id}`, err); }); } catch (err) { log$f.error(`Magnet cache lookup failed for ${id}`, err); CoreEvents.emit(AppEvents.MAGNET_FAILED, { id }); } } else { CoreEvents.emit(AppEvents.MAGNET_FAILED, { id }); } }); CoreEvents.on(AppEvents.MAGNET_FOUND, (data) => this.handleMagnetResult(data.id, data.url)); CoreEvents.on(AppEvents.MAGNET_FAILED, (data) => this.handleMagnetResult(data.id, null)); CoreEvents.on(AppEvents.HISTORY_ADDED, (data) => this.syncUIWithHistory(data.id, data.status)); CoreEvents.on(AppEvents.HISTORY_REMOVED, (data) => this.syncUIWithHistory(data.id, "none")); CoreEvents.on(AppEvents.HISTORY_LOADED, () => this.updateVisibilityForCards()); } updateGlobalClasses() { const isShowViewed = State.proxy.showViewedBtn !== false; const isShowId = State.proxy.showIdBadge !== false; document.body.classList.toggle("hide-viewed-btn", !isShowViewed); document.body.classList.toggle("hide-id-badge", !isShowId); } updateVisibilityForCards() { const C = Config.CLASSES; document.querySelectorAll(`.${C.cardRebuilt}`).forEach((card2) => { const cardEl = card2; this.observer?.observe(cardEl); }); } handleMagnetResult(id, url) { ViewStore.set(id, "isSearching", false); ViewStore.set(id, "hasMagnet", !!url); ViewStore.set(id, "magnetUrl", url); } syncUIWithHistory(id, status) { ViewStore.set(IdNormalizer.normalize(id), "status", status); } } AppContainer.register("ui-enhancements", new UIEnhancementService()); const log$e = Logger.scope("Supabase"); class SupabaseClient { static getConfig() { let url = State.proxy.supabaseUrl || ""; if (url.endsWith("/")) url = url.slice(0, -1); url = url.trim(); const key = (State.proxy.supabaseKey || "").trim(); if (key) { const masked = key.length > 10 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***"; log$e.debug(`Using API Key (len: ${key.length}, masked: ${masked})`); } return { url, key }; } static async request(endpoint, method, body = null, headers = { }) { const { url, key } = this.getConfig(); if (!url || !key) throw new Error("No Supabase config"); log$e.trace(`Request: ${method} ${endpoint}`, { body }); try { const response = await http(`${url}${endpoint}`, { method, headers: { apikey: key, Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...headers }, data: body ?? void 0 }); log$e.trace(`Response: ${method} ${endpoint}`, { response }); return response; } catch (e) { const err = e; if (err.status === 401 || err.status === 403) { const { key: key2 } = this.getConfig(); if (key2.length < 100) { log$e.warn("Auth failed, API key may not be a standard JWT"); } } log$e.error(`Request failed: ${method} ${endpoint}`, e); throw e; } } static async getAuthHeader() { const jwt = GM_getValue(STORAGE_KEYS.SUPABASE_JWT); if (jwt) { try { const parts = jwt.split("."); if (parts.length >= 2 && parts[1]) { const payload = JSON.parse(atob(parts[1])); if (payload.exp * 1e3 > Date.now() + 6e4) return `Bearer ${jwt}`; } } catch { } } const refresh2 = GM_getValue(STORAGE_KEYS.SUPABASE_REFRESH); if (!refresh2) return null; try { const data = await this.request( `${SUPABASE_ENDPOINTS.TOKEN}?grant_type=refresh_token`, "POST", { refresh_token: refresh2 } ); if (data?.access_token) { GM_setValue(STORAGE_KEYS.SUPABASE_JWT, data.access_token); GM_setValue(STORAGE_KEYS.SUPABASE_REFRESH, data.refresh_token); GM_setValue(STORAGE_KEYS.SYNC_USER_ID, data.user.id); return `Bearer ${data.access_token}`; } } catch (e) { log$e.error("Token refresh failed", e); const err = e; if (err.status === 400 || err.status === 401 || err.response && err.response.includes("invalid_grant")) { [ STORAGE_KEYS.SYNC_USER_ID, STORAGE_KEYS.SUPABASE_JWT, STORAGE_KEYS.SUPABASE_REFRESH, STORAGE_KEYS.CURRENT_USER_EMAIL, STORAGE_KEYS.LAST_SYNC_TS ].forEach((k) => GM_deleteValue(k)); } } return null; } } const log$d = Logger.scope("Supabase"); class SupabaseProviderImpl { constructor() { this.name = "supabase"; this.isSyncing = false; this.needsRetry = false; this._serverSupportsMetadata = true; this._onProgress = null; } onInit() { } logout(silent = false) { [ STORAGE_KEYS.SYNC_USER_ID, STORAGE_KEYS.SUPABASE_JWT, STORAGE_KEYS.SUPABASE_REFRESH, STORAGE_KEYS.CURRENT_USER_EMAIL, STORAGE_KEYS.LAST_SYNC_TS ].forEach((k) => GM_deleteValue(k)); if (!silent) { CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertLoggedOut"), type: "success" }); setTimeout(() => location.reload(), TIMING.RELOAD_DELAY_FAST); } } async login(e, p) { const data = await SupabaseClient.request( `${SUPABASE_ENDPOINTS.TOKEN}?grant_type=password`, "POST", { email: e, password: p } ); if (data?.access_token) { GM_setValue(STORAGE_KEYS.SYNC_USER_ID, data.user.id); GM_setValue(STORAGE_KEYS.SUPABASE_JWT, data.access_token); GM_setValue(STORAGE_KEYS.SUPABASE_REFRESH, data.refresh_token); GM_setValue(STORAGE_KEYS.CURRENT_USER_EMAIL, data.user.email); GM_setValue(STORAGE_KEYS.LAST_SYNC_TS, UI_CONSTANTS.DEFAULT_TIMESTAMP); return data.user; } throw new Error("Login failed"); } async signup(e, p) { return SupabaseClient.request(SUPABASE_ENDPOINTS.SIGNUP, "POST", { email: e, password: p }); } get onProgress() { return this._onProgress; } set onProgress(val) { this._onProgress = val; } async performSync(isManual = false, forceRefresh = false, preferRemote = false, traceId) { if (this.isSyncing) { this.needsRetry = true; return; } if (preferRemote) forceRefresh = true; const runSync = async () => { Logger.group("Supabase", `Sync (Force: ${forceRefresh})`, traceId); this.isSyncing = true; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("labelSyncing"), type: "info" }); try { State.proxy.syncStatus = SYNC_STATUS.SYNCING; const auth = await SupabaseClient.getAuthHeader(); if (!auth) { log$d.debug("No auth session, skipping sync", void 0, traceId); State.proxy.syncStatus = SYNC_STATUS.IDLE; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertLoginRequired"), type: "warn" }); return; } const lastSync = forceRefresh ? UI_CONSTANTS.DEFAULT_TIMESTAMP : GM_getValue(STORAGE_KEYS.LAST_SYNC_TS, UI_CONSTANTS.DEFAULT_TIMESTAMP); const syncStartedAt = ( new Date()).toISOString(); if (!preferRemote) { const dirty = await Repository.db.history.where("sync_dirty").equals(1).limit(200).toArray(); if (dirty.length > 0) { const userId = GM_getValue(STORAGE_KEYS.SYNC_USER_ID); if (this._onProgress) this._onProgress({ phase: "Pushing local data", percent: 30 }); const payload = await Promise.all( dirty.map(async (r) => { const item = { fc2_id: isNaN(Number(r.id)) ? r.id : parseInt(r.id, 10), last_watched_at: new Date(r.timestamp).toISOString(), status: r.status || "watched", is_deleted: !!r.is_deleted, user_id: userId || null, metadata: null }; if (this._serverSupportsMetadata) { const details = r.status === "wanted" ? await Repository.details.get(r.id) : null; item.metadata = details || null; } else { delete item.metadata; } return item; }) ); if (payload.length > 0) { const keys = Object.keys(payload[0]).sort(); log$d.debug("Sync payload", { count: payload.length, keys }); if (payload.length > 1) { const keysLast = Object.keys(payload[payload.length - 1]).sort().join(","); if (keys.join(",") !== keysLast) { log$d.error("Payload keys inconsistent", { first: keys.join(","), last: keysLast }); } } } await SupabaseClient.request(SUPABASE_ENDPOINTS.USER_HISTORY, "POST", payload, { Authorization: auth, Prefer: "resolution=merge-duplicates" }); await Repository.db.history.where("id").anyOf(dirty.map((r) => r.id)).modify({ sync_dirty: 0, retry_count: 0 }); } } if (this._onProgress) this._onProgress({ phase: "Fetching remote data", percent: 60 }); let page = 0, hasMore = true; while (hasMore) { const remote = await SupabaseClient.request( `${SUPABASE_ENDPOINTS.USER_HISTORY}?updated_at=gt.${encodeURIComponent(lastSync)}&select=fc2_id,last_watched_at,is_deleted,updated_at,status${this._serverSupportsMetadata ? ",metadata" : ""}&order=updated_at.asc`, "GET", null, { Authorization: auth, Range: `${page * 1e3}-${(page + 1) * 1e3 - 1}` } ); if (remote?.length) { const history2 = [], deletes = []; remote.forEach((i) => { if (i.is_deleted) deletes.push(String(i.fc2_id)); else history2.push({ id: String(i.fc2_id), timestamp: new Date(i.last_watched_at).getTime(), status: i.status || "watched", updated_at: i.updated_at, is_deleted: 0, sync_dirty: 0 }); }); await Repository.db.transaction( "rw", Repository.db.history, Repository.db.itemDetails, async () => { if (history2.length) await Repository.db.history.bulkPut(history2); if (deletes.length) await Repository.db.history.where("id").anyOf(deletes).modify({ is_deleted: 1, sync_dirty: 0 }); for (const i of remote) if (i.metadata && !i.is_deleted) await Repository.details.set(String(i.fc2_id), i.metadata); } ); if (remote.length < 1e3) hasMore = false; else page++; } else hasMore = false; } await HistoryService.load(); GM_setValue(STORAGE_KEYS.LAST_SYNC_TS, syncStartedAt); State.proxy.syncStatus = SYNC_STATUS.SUCCESS; log$d.info("Sync completed"); if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertWebDAVSyncSuccess"), type: "success" }); } catch (e) { const err = e; if (err.status === 400 || err.message?.includes("metadata")) { log$d.warn("Metadata column missing, falling back", void 0, traceId); this._serverSupportsMetadata = false; this.needsRetry = true; } else { log$d.error("Sync failed", e, traceId); } State.proxy.syncStatus = SYNC_STATUS.ERROR; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertWebDAVSyncError") + (err.message || ""), type: "error" }); } finally { this.isSyncing = false; Logger.groupEnd(); if (this.needsRetry) { this.needsRetry = false; this.performSync(false, false, false, traceId); } } }; await runSync(); } } const SupabaseProvider = AppContainer.register( "supabase-provider", new SupabaseProviderImpl() ); class MergeEngine { static merge(localHistory, remoteHistory, localDetails, remoteDetails, preferRemote = false) { const historyMap = new Map(); const detailMap = new Map(); if (remoteHistory) { remoteHistory.forEach((r) => { if (!r.updated_at) r.updated_at = new Date(r.timestamp || 0).toISOString(); historyMap.set(r.id, r); }); } if (remoteDetails) { remoteDetails.forEach((d) => { if (!d.updated_at) d.updated_at = ( new Date(0)).toISOString(); detailMap.set(d.id, d); }); } for (const local of localHistory) { const remote = historyMap.get(local.id); if (!local.updated_at) local.updated_at = new Date(local.timestamp).toISOString(); if (!remote) { historyMap.set(local.id, local); } else { const localTime = new Date(local.updated_at).getTime(); const remoteTime = new Date(remote.updated_at).getTime(); if (!preferRemote && localTime >= remoteTime) { historyMap.set(local.id, local); } } } for (const local of localDetails) { const remote = detailMap.get(local.id); if (!local.updated_at) local.updated_at = ( new Date(0)).toISOString(); if (!remote) { detailMap.set(local.id, local); } else { const localTime = new Date(local.updated_at).getTime(); const remoteTime = new Date(remote.updated_at).getTime(); if (!preferRemote && localTime >= remoteTime) { detailMap.set(local.id, local); } } } return { history: Array.from(historyMap.values()), details: Array.from(detailMap.values()) }; } } const log$c = Logger.scope("WebDAV"); class WebDAVClient { static getAuthHeader() { const { webdavUser, webdavPass } = State.proxy; return "Basic " + btoa(`${webdavUser}:${webdavPass}`); } static async request(method, url, body = null, headers = {}, responseType = "text") { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers: { Authorization: this.getAuthHeader(), ...headers }, data: body, responseType, timeout: 3e4, onload: resolve, onerror: reject, ontimeout: () => reject(new Error("WebDAV Timeout")) }); }); } static async ensureDirectory(baseUrl, fullPath) { const parts = fullPath.split("/").filter((p) => p && !p.includes(".")); let currentPath = baseUrl.replace(/\/$/, ""); for (const part of parts) { currentPath += "/" + part; try { const res = await this.request("PROPFIND", currentPath, null, { Depth: "0" }); if (res.status === 404) { log$c.debug(`Creating directory: ${currentPath}`); await this.request("MKCOL", currentPath); } } catch (e) { log$c.error(`EnsureDirectory failed: ${currentPath}`, e); } } } static async fetchFile(url, isGzip) { try { const res = await this.request("GET", url, null, {}, isGzip ? "blob" : "text"); if (res.status === 200) { const etag = res.responseHeaders.match(/etag:\s*(.*)/i)?.[1]?.replace(/"/g, "").trim(); let text = res.responseText; if (isGzip && res.response) { text = await GzipService.decompress(res.response); } return { data: JSON.parse(text), etag }; } } catch (e) { log$c.debug(`Fetch failed for ${url}`, e); } return null; } } const log$b = Logger.scope("WebDAV"); class WebDAVProviderImpl { constructor() { this.name = "webdav"; this.isSyncing = false; } onInit() { } async runSync(isManual = false, forceRefresh = false, retryOnConflict = true, preferRemote = false, traceId) { if (this.isSyncing) return; const now = Date.now(); const lastLock = GM_getValue(STORAGE_KEYS.WEBDAV_SYNC_LOCK, 0); if (now - lastLock < 1e4) { if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertSyncLockActive"), type: "warn" }); return; } GM_setValue(STORAGE_KEYS.WEBDAV_SYNC_LOCK, now); const { webdavUrl, webdavPath } = State.proxy; if (!webdavUrl || !webdavPath) return; Logger.group("WebDAV", `Sync (Force: ${forceRefresh}, CloudFirst: ${preferRemote})`, traceId); this.isSyncing = true; State.proxy.syncStatus = SYNC_STATUS.SYNCING; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("labelSyncing"), type: "info" }); try { await WebDAVClient.ensureDirectory(webdavUrl, webdavPath); const baseUrl = webdavUrl.replace(/\/$/, ""); const targetUrl = `${baseUrl}/${webdavPath}${GzipService.isSupported() ? ".gz" : ""}`; const useGzip = GzipService.isSupported(); const headRes = await WebDAVClient.request("HEAD", targetUrl); const remoteETag = headRes.status === 200 ? headRes.responseHeaders.match(/etag:\s*(.*)/i)?.[1]?.replace(/"/g, "").trim() : void 0; const lastEtag = GM_getValue(STORAGE_KEYS.WEBDAV_LAST_ETAG); const dirtyCount = await Repository.db.history.where("sync_dirty").equals(1).count(); if (!preferRemote && !forceRefresh && remoteETag && lastEtag === remoteETag && dirtyCount === 0) { State.proxy.syncStatus = SYNC_STATUS.SUCCESS; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertAlreadyUpToDate"), type: "info" }); return; } let remoteData; if (forceRefresh || preferRemote || !lastEtag || lastEtag !== remoteETag || headRes.status === 404) { const fetched = await WebDAVClient.fetchFile(targetUrl, useGzip); if (fetched?.data) { const d = fetched.data; if (d.checksum && await CryptoService.calculateChecksum(d.history) !== d.checksum) { throw new Error("Remote checksum mismatch"); } remoteData = d; } } const localHistory = await Repository.db.history.toArray(); const localDetails = await Repository.db.itemDetails.toArray(); const { history: mergedHistory, details: mergedDetails } = MergeEngine.merge( localHistory, remoteData?.history, localDetails, remoteData?.itemDetails, preferRemote ); const payload = { version: 2, updated_at: ( new Date()).toISOString(), history: mergedHistory, itemDetails: mergedDetails, checksum: await CryptoService.calculateChecksum(mergedHistory) }; const putHeaders = { "Content-Type": useGzip ? "application/gzip" : "application/json" }; if (remoteETag) putHeaders["If-Match"] = `"${remoteETag.replace(/"/g, "")}"`; const body = useGzip ? await GzipService.compress(JSON.stringify(payload)) : JSON.stringify(payload); const res = await WebDAVClient.request("PUT", targetUrl, body, putHeaders); if (res.status >= 200 && res.status < 300) { const newEtag = res.responseHeaders.match(/etag:\s*(.*)/i)?.[1]?.replace(/"/g, "").trim() || remoteETag; if (newEtag) GM_setValue(STORAGE_KEYS.WEBDAV_LAST_ETAG, newEtag); await Repository.db.transaction("rw", Repository.db.history, Repository.db.itemDetails, async () => { await Repository.db.history.where("sync_dirty").equals(1).modify({ sync_dirty: 0, retry_count: 0 }); await Repository.db.history.bulkPut(mergedHistory.map((h2) => ({ ...h2, sync_dirty: 0 }))); await Repository.db.itemDetails.bulkPut(mergedDetails); }); await HistoryService.load(); State.proxy.syncStatus = SYNC_STATUS.SUCCESS; State.proxy.lastSyncTime = ( new Date()).toISOString(); if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertWebDAVSyncSuccess"), type: "success" }); } else if (res.status === 412 && retryOnConflict) { this.isSyncing = false; return this.runSync(isManual, true, false, false, traceId); } else { throw new Error(`WebDAV PUT failed: ${res.status}`); } } catch (e) { const err = e; log$b.error("Sync error", e, traceId); State.proxy.syncStatus = SYNC_STATUS.ERROR; if (isManual) CoreEvents.emit(AppEvents.SHOW_TOAST, { message: t("alertWebDAVSyncError") + (err.message || ""), type: "error" }); } finally { this.isSyncing = false; GM_setValue(STORAGE_KEYS.WEBDAV_SYNC_LOCK, 0); Logger.groupEnd(); } } async test() { const { webdavUrl } = State.proxy; if (!webdavUrl) throw new Error("URL is empty"); return WebDAVClient.request("PROPFIND", webdavUrl.replace(/\/$/, "") + "/", null, { Depth: "0" }); } async logout() { [ STORAGE_KEYS.WEBDAV_URL, STORAGE_KEYS.WEBDAV_USER, STORAGE_KEYS.WEBDAV_PASS, STORAGE_KEYS.WEBDAV_PATH, STORAGE_KEYS.WEBDAV_LAST_ETAG ].forEach((k) => GM_deleteValue(k)); } async performSync(isManual = false, forceRefresh = false, preferRemote = false, traceId) { if (forceRefresh || preferRemote) GM_deleteValue(STORAGE_KEYS.WEBDAV_LAST_ETAG); await RetryManager.executeWithRetry( () => this.runSync(isManual, forceRefresh, true, preferRemote, traceId), RetryManager.configs.sync ); } } const WebDAVProvider = AppContainer.register("webdav-provider", new WebDAVProviderImpl()); const log$a = Logger.scope("Sync"); class SyncServiceImplementation { constructor() { this.syncTimer = null; } onBootstrap() { log$a.debug("Starting auto-sync scheduler"); setTimeout( () => this.performSync(false).catch((e) => { log$a.warn("Initial auto-sync failed", e); }), TIMING.SYNC_INIT_DELAY ); CoreEvents.on(AppEvents.HISTORY_CHANGED, () => { this.requestSync(); }); } getProvider() { const mode = State.proxy.syncMode; if (mode === "webdav") return WebDAVProvider; if (mode === "supabase") return SupabaseProvider; return null; } set onProgress(val) { SupabaseProvider.onProgress = val; } async login(email, password) { return await SupabaseProvider.login(email, password); } async signup(email, password) { return await SupabaseProvider.signup(email, password); } async logout(silent = false) { const provider = this.getProvider(); if (provider) { await provider.logout(); } else { SupabaseProvider.logout(silent); } State.proxy.syncMode = "none"; State.proxy.syncStatus = SYNC_STATUS.IDLE; GM_setValue(STORAGE_KEYS.LAST_SYNC_TS, UI_CONSTANTS.DEFAULT_TIMESTAMP); } async testWebDAV() { return await WebDAVProvider.test(); } requestSync() { const interval = State.proxy.syncInterval; const mode = State.proxy.syncMode; if (interval === -1 || mode === "none") return; if (interval === 0) { Logger.info("SyncService", `Sync requested (real-time mode), executing in ${TIMING.SYNC_DEBOUNCE_MS}ms`); if (this.syncTimer) clearTimeout(this.syncTimer); this.syncTimer = setTimeout( () => this.performSync().catch((e) => log$a.error("Debounced sync failed", e)), TIMING.SYNC_DEBOUNCE_MS ); return; } const lastAutoSync = GM_getValue(STORAGE_KEYS.LAST_AUTO_SYNC_TS, 0); const now = Date.now(); const minInterval = interval * 60 * 1e3; if (now - lastAutoSync < minInterval) return; Logger.info( "SyncService", `Sync requested (interval: ${interval}min), executing in ${TIMING.SYNC_DEBOUNCE_MS}ms` ); if (this.syncTimer) clearTimeout(this.syncTimer); this.syncTimer = setTimeout(() => { GM_setValue(STORAGE_KEYS.LAST_AUTO_SYNC_TS, Date.now()); this.performSync().catch((e) => log$a.error("Scheduled sync failed", e)); }, TIMING.SYNC_DEBOUNCE_MS); } async performSync(isManual = false, forceRefresh = false, preferRemote = false) { const traceId = Logger.traceId; try { const provider = this.getProvider(); if (!provider) return; await navigator.locks.request("fc2_sync_lock", { ifAvailable: true }, async (lock) => { if (!lock) { if (isManual) { log$a.info("Manual sync skipped: lock held by another tab"); Toast.show(t("alertSyncLocked") || "Sync already in progress in another tab", "warn"); } else { log$a.debug("Auto-sync skipped: lock held by another tab"); } return; } log$a.info(`Starting sync (manual: ${isManual}, force: ${forceRefresh})`, traceId); await provider.performSync(isManual, forceRefresh, preferRemote, traceId); }); } catch (error) { log$a.error("Sync failed", error, traceId); State.proxy.syncStatus = SYNC_STATUS.ERROR; } } async forceFullSync() { if (!confirm(t("alertPushAllQuery"))) return; await Repository.db.history.toCollection().modify({ sync_dirty: 1 }); GM_setValue(STORAGE_KEYS.LAST_SYNC_TS, UI_CONSTANTS.DEFAULT_TIMESTAMP); return await this.performSync(true, true); } async forcePullSync() { if (!confirm(t("alertPullAllQuery"))) return; GM_setValue(STORAGE_KEYS.LAST_SYNC_TS, UI_CONSTANTS.DEFAULT_TIMESTAMP); return await this.performSync(true, true, true); } } const SyncService = AppContainer.register("sync-service", new SyncServiceImplementation()); const log$9 = Logger.scope("Cleanup"); class CleanupServiceImplementation { constructor() { this.RETENTION_PERIOD = 30 * 24 * 60 * 60 * 1e3; } onBootstrap() { log$9.debug("Scheduling maintenance"); setTimeout(() => { this.runAllGC().catch((e) => log$9.error("Maintenance failed", e)); }, 5e3); } async runAllGC() { Logger.group("Cleanup", "System maintenance"); try { await this.runTombstoneGC(); await Repository.runGC(); log$9.info("Maintenance complete"); } finally { Logger.groupEnd(); } } async runTombstoneGC() { try { const thresholdDate = new Date(Date.now() - this.RETENTION_PERIOD).toISOString(); const deletedCount = await Repository.db.history.where("is_deleted").equals(1).and((item) => item.updated_at < thresholdDate).delete(); if (deletedCount > 0) { log$9.info(`Purged ${deletedCount} tombstone records`); } } catch (e) { log$9.error("Tombstone GC failed", e); } } } AppContainer.register("cleanup-service", new CleanupServiceImplementation()); const log$8 = Logger.scope("Preview"); class PreviewServiceImplementation { constructor() { this.cache = new Map(); this.maxCacheSize = CACHE.PREVIEW_MAX_SIZE; this.preloadQueue = new Set(); } async onBootstrap() { log$8.debug("Binding global preview events"); this.initGlobalEvents(); } registerContainer(container2) { const mode = State.proxy.previewMode; if (mode === "static") return; const selector = `.${Config.CLASSES.processedCard}`; const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; if (isTouch) { container2.addEventListener( "click", (e) => { const imgEl = e.target.closest(`img.${Config.CLASSES.staticPreview}`); if (imgEl) { const card2 = imgEl.closest(selector); if (card2 && !card2.querySelector("video")) { e.preventDefault(); e.stopPropagation(); this._loadVideoProgressive(card2); } } }, true ); } else if (mode === "hover") { container2.addEventListener( "mouseenter", (e) => { const card2 = e.target.closest(selector); if (card2) { this._loadVideoProgressive(card2); this._smartPreload(card2); } }, true ); } } initGlobalEvents() { const mode = State.proxy.previewMode; if (mode === "static") return; this.registerContainer(document.body); window.addEventListener("beforeunload", () => this.clearCache()); } _loadVideoProgressive(card2) { const cont = card2.querySelector(`.${Config.CLASSES.videoPreviewContainer}`); const img = cont?.querySelector(`img.${Config.CLASSES.staticPreview}`); if (!cont || !img) return; const { id, type, previewSlug } = card2.dataset; const url = this._getPreviewUrl(id, type, previewSlug); const cached = this.cache.get(id); if (cached && cached.element instanceof HTMLVideoElement) { const video2 = cached.element; if (video2.parentNode !== cont) { if (video2.parentNode) video2.remove(); cont.appendChild(video2); } cont.classList.add("fc2-preview-active"); video2.classList.remove(Config.CLASSES.hidden); video2.classList.add("fc2-reveal-content"); img.classList.add(Config.CLASSES.hidden); video2.play().catch(() => { }); cached.timestamp = Date.now(); this._attachWarmCleanup(card2, video2, img, cont); return; } const existingVideo = cont.querySelector("video"); if (existingVideo) { cont.classList.add("fc2-preview-active"); existingVideo.classList.remove(Config.CLASSES.hidden); img.classList.add(Config.CLASSES.hidden); existingVideo.play().catch(() => { }); this._attachWarmCleanup(card2, existingVideo, img, cont); return; } this._showLoadingIndicator(cont); const video = this._createVideoElement(url); cont.appendChild(video); let isStillHovered = true; const isTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; if (!isTouch) { this._attachWarmCleanup(card2, video, img, cont, () => { isStillHovered = false; }); } this._attachLoadingEventsWithCheck(video, cont, img, () => isStillHovered, id); } _attachWarmCleanup(card2, video, img, cont, onCleanup) { const cleanup = () => { if (onCleanup) onCleanup(); cont.classList.remove("fc2-preview-active"); if (video.isConnected) { video.pause(); video.classList.add(Config.CLASSES.hidden); video.classList.remove("fc2-reveal-content"); } img.classList.remove(Config.CLASSES.hidden); this._hideLoadingIndicator(cont); const id = card2.dataset.id; if (id) this._cachePreview(id, video); }; card2.addEventListener("mouseleave", cleanup, { once: true }); } _createVideoElement(url) { return h("video", { src: url, autoplay: true, loop: true, muted: true, playsInline: true, preload: "auto", className: `${Config.CLASSES.previewElement} ${Config.CLASSES.hidden}` }); } _attachLoadingEventsWithCheck(video, cont, img, checkHover, id) { video.addEventListener("progress", () => { if (video.buffered.length > 0 && checkHover()) { const percent = video.buffered.end(0) / video.duration * 100; this._updateLoadingProgress(cont, percent); } }); video.addEventListener( "playing", () => { if (!checkHover()) { video.pause(); this._cachePreview(id, video); return; } requestAnimationFrame(() => { if (checkHover()) { cont.classList.add("fc2-preview-active"); video.classList.remove(Config.CLASSES.hidden); video.classList.add("fc2-reveal-content"); img.classList.add(Config.CLASSES.hidden); this._hideLoadingIndicator(cont); } else { video.pause(); this._cachePreview(id, video); } }); }, { once: true } ); video.play().catch(() => { }); video.addEventListener("error", () => { if (checkHover()) { this._hideLoadingIndicator(cont); this._showErrorIndicator(cont); } }); } _showLoadingIndicator(cont) { if (cont.querySelector(".preview-loading")) return; const loader = h( "div", { className: "preview-loading", style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 10;" }, h("div", { className: "preview-spinner", style: "width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: fc2-spin 0.8s linear infinite;" }), h( "div", { className: "preview-progress", style: "margin-top: 10px; color: #fff; font-size: 12px; text-align: center;" }, "加载中..." ) ); cont.appendChild(loader); } _updateLoadingProgress(cont, percent) { const progress = cont.querySelector(".preview-progress"); if (progress) progress.textContent = `${Math.round(percent)}%`; } _hideLoadingIndicator(cont) { cont.querySelector(".preview-loading")?.remove(); } _showErrorIndicator(cont) { const error = h( "div", { className: "preview-error", style: "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #f87171; font-size: 12px; text-align: center; z-index: 10;" }, h("span", { className: "fc2-icon", innerHTML: IconTriangleExclamation, style: "font-size: 24px; margin-bottom: 5px; display: inline-block;" }), h("div", {}, "预览加载失败") ); cont.appendChild(error); setTimeout(() => error.remove(), TIMING.PREVIEW_ERROR_DELAY); } _getPreviewUrl(id, type, previewSlug) { if (previewSlug) return `${EXTERNAL_URLS.FOURHOI_BASE}/${previewSlug.toLowerCase()}/preview.mp4`; if (type === "fc2") return `${EXTERNAL_URLS.FOURHOI_BASE}/fc2-ppv-${id}/preview.mp4`; return `${EXTERNAL_URLS.FOURHOI_BASE}/${id.toLowerCase()}/preview.mp4`; } async _smartPreload(currentCard) { const cards = Array.from(document.querySelectorAll(`.${Config.CLASSES.processedCard}`)); const currentIndex = cards.indexOf(currentCard); if (currentIndex === -1) return; const toPreload = [cards[currentIndex + 1], cards[currentIndex - 1]].filter(Boolean); for (const card2 of toPreload) { const { id } = card2.dataset; if (id && !this.preloadQueue.has(id)) { if (this.preloadQueue.size >= CACHE.PREVIEW_PRELOAD_LIMIT) { const first = this.preloadQueue.values().next().value; if (first) this.preloadQueue.delete(first); } this.preloadQueue.add(id); this._preloadVideo(card2); } } } _preloadVideo(card2) { if (card2.querySelector("video")) return; const { id, type, previewSlug } = card2.dataset; const url = this._getPreviewUrl(id, type, previewSlug); const link = document.createElement("link"); link.rel = "preload"; link.as = "video"; link.href = url; document.head.appendChild(link); setTimeout(() => { if (link.parentNode) link.remove(); }, TIMING.LINK_PRELOAD_TIMEOUT); } _cachePreview(id, element) { if (this.cache.has(id)) { this.cache.get(id).timestamp = Date.now(); return; } if (this.cache.size >= this.maxCacheSize) { const entries = Array.from(this.cache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp); const oldest = entries[0]; if (oldest) { const oldestId = oldest[0]; const oldestItem = oldest[1]; if (oldestItem.element instanceof HTMLVideoElement) { oldestItem.element.pause(); oldestItem.element.src = ""; oldestItem.element.load(); oldestItem.element.remove(); } this.cache.delete(oldestId); } } this.cache.set(id, { url: element.src, element, timestamp: Date.now() }); } clearCache() { this.cache.forEach((item) => { if (item.element instanceof HTMLVideoElement) { item.element.pause(); item.element.src = ""; item.element.load(); item.element.remove(); } }); this.cache.clear(); log$8.debug("Cache cleared"); } } AppContainer.register("preview-service", new PreviewServiceImplementation()); const log$7 = Logger.scope("Menu"); class MenuServiceImplementation { constructor() { this.menuIds = []; } onBootstrap() { log$7.debug("Registering UserScript menus"); this.register(); } register() { if (typeof GM_registerMenuCommand === "undefined") return; this.menuIds.forEach((id) => { try { GM_unregisterMenuCommand(id); } catch { } }); this.menuIds = []; const settingsId = GM_registerMenuCommand(t("menuOpenSettings"), () => { CoreEvents.emit(AppEvents.OPEN_SETTINGS, { }); }); this.menuIds.push(settingsId); } } const MenuService = AppContainer.register("menu-service", new MenuServiceImplementation()); const log$6 = Logger.scope("Migration"); class MigrationServiceImplementation { onBootstrap() { log$6.debug("Checking for pending migrations"); this.run().catch((e) => log$6.error("Run failed", e)); } async run() { const traceId = Logger.traceId; const currentVersion = Storage.get("migration_version", 0); const TARGET_VERSION = 1; if (currentVersion >= TARGET_VERSION) { return; } Logger.group("Migration", `v${currentVersion} -> v${TARGET_VERSION}`, traceId); try { await this.migrateHistory(traceId); await this.migrateCache(traceId); Storage.set("migration_version", TARGET_VERSION); log$6.info("Migration completed", void 0, traceId); } catch (e) { log$6.error("Migration failed", e, traceId); } finally { Logger.groupEnd(); } } async migrateHistory(traceId) { const oldKey = "history_v1"; const oldDataStr = Storage.get(oldKey, null); if (!oldDataStr) { log$6.debug("No old history found", void 0, traceId); return; } try { let oldData = oldDataStr; if (typeof oldDataStr === "string") { try { oldData = JSON.parse(oldDataStr); } catch { log$6.warn("Failed to parse history JSON", void 0, traceId); } } if (!Array.isArray(oldData)) { log$6.warn("Invalid history format (not array)", typeof oldData, traceId); return; } log$6.info(`Found ${oldData.length} raw history items`, void 0, traceId); const now = ( new Date()).toISOString(); const validItemsMap = new Map(); for (const item of oldData) { try { let id = ""; let timestamp = Date.now(); if (typeof item === "number") { id = String(item); } else if (typeof item === "string") { id = item.trim(); } else if (typeof item === "object" && item !== null) { if (item.id) id = String(item.id).trim(); if (typeof item.timestamp === "number" && !isNaN(item.timestamp) && item.timestamp > 0) { timestamp = item.timestamp; } } if (!id) continue; if (validItemsMap.has(id)) { const existing = validItemsMap.get(id); if (timestamp > existing.timestamp) { existing.timestamp = timestamp; } } else { validItemsMap.set(id, { id, timestamp, status: "watched", updated_at: now, is_deleted: 0, sync_dirty: 1 }); } } catch { log$6.warn("Skipping malformed history item", item, traceId); } } const dbItems = Array.from(validItemsMap.values()); if (dbItems.length > 0) { await Repository.db.history.bulkPut(dbItems); log$6.info(`Migrated ${dbItems.length} unique history items`, void 0, traceId); } } catch (e) { log$6.error("Critical error migrating history", e, traceId); } } async migrateCache(traceId) { const oldKey = "magnet_cache_v1"; const oldDataStr = Storage.get(oldKey, null); if (!oldDataStr) { return; } try { let oldData = oldDataStr; if (typeof oldDataStr === "string") { try { oldData = JSON.parse(oldDataStr); } catch { } } if (typeof oldData !== "object" || oldData === null) return; const entries = Object.entries(oldData); log$6.info(`Found ${entries.length} cache items to migrate`, void 0, traceId); const validItems = []; const now = Date.now(); for (const [key, val] of entries) { try { const id = String(key).trim(); if (!id) continue; const valueObj = val; if (!valueObj || typeof valueObj.v !== "string") continue; const magnetUrl = valueObj.v; if (!magnetUrl.startsWith("magnet:?")) continue; let timestamp = now; if (typeof valueObj.t === "number" && !isNaN(valueObj.t) && valueObj.t > 0) { timestamp = valueObj.t; } validItems.push({ id, value: magnetUrl, timestamp }); } catch { } } if (validItems.length > 0) { await Repository.db.cache.bulkPut(validItems); log$6.info(`Migrated ${validItems.length} cache items`, void 0, traceId); } } catch (e) { log$6.error("Error migrating cache", e, traceId); } } } AppContainer.register("migration-service", new MigrationServiceImplementation()); const log$5 = Logger.scope("Backup"); const _BackupServiceImplementation = class _BackupServiceImplementation { async onInit() { log$5.debug("Service initialized"); } async exportData() { const history2 = await Repository.history.getAll(); const details = await Repository.details.getAll(); const { syncStatus: _syncStatus, ...persistentSettings } = State.proxy; const data = { appName: SCRIPT_INFO.NAME, version: 3, timestamp: Date.now(), settings: persistentSettings, history: history2, details }; try { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const dateStr = ( new Date()).toISOString().slice(0, 10); a.href = url; a.download = `fc2_enhanced_backup_${dateStr}.json`; a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); log$5.info("Data exported successfully"); return true; } catch (err) { log$5.error("Export failed", err); return false; } } async importData(file) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = async (e) => { try { const content = e.target?.result; const data = JSON.parse(content); if (!data.settings || !data.history) { log$5.error("Invalid backup file format"); resolve(false); return; } log$5.debug("Importing settings..."); for (const [key, value] of Object.entries(data.settings)) { if (_BackupServiceImplementation.ALLOWED_SETTINGS_KEYS.has(key)) { State.proxy[key] = value; } else { log$5.warn(`Skipping unknown settings key during import: ${key}`); } } log$5.debug("Importing history & metadata..."); await Repository.db.transaction( "rw", [Repository.db.history, Repository.db.itemDetails], async () => { if (Array.isArray(data.history)) { await Repository.db.history.bulkPut(data.history); } if (Array.isArray(data.details)) { const validDetails = data.details.filter((d) => d.id); if (validDetails.length > 0) { await Repository.db.itemDetails.bulkPut(validDetails); } } } ); log$5.info(`Imported ${data.history.length} history items and metadata`); resolve(true); } catch (err) { log$5.error("Import failed during parsing/saving", err); resolve(false); } }; reader.onerror = () => resolve(false); reader.readAsText(file); }); } }; _BackupServiceImplementation.ALLOWED_SETTINGS_KEYS = new Set([ "previewMode", "hideNoMagnet", "hideCensored", "enableHistory", "hideViewed", "enableFollows", "loadExtraPreviews", "enableQuickBar", "showViewedBtn", "showIdBadge", "enableMagnets", "enableExternalLinks", "enableActressName", "hideBlocked", "hideUnwanted", "language", "syncMode", "syncInterval", "replaceFc2Covers", "enabledPortals", "userGridColumns", "debugMode", "customFolders", "collectionCardWidth" ]); let BackupServiceImplementation = _BackupServiceImplementation; const BackupService = AppContainer.register("backup-service", new BackupServiceImplementation()); const scriptRel = (function detectScriptRel() { const relList = typeof document !== "undefined" && document.createElement("link").relList; return relList && relList.supports && relList.supports("modulepreload") ? "modulepreload" : "preload"; })(); const assetsURL = function(dep) { return "/" + dep; }; const seen = {}; const __vitePreload = function preload(baseModule, deps, importerUrl) { let promise = Promise.resolve(); if (deps && deps.length > 0) { let allSettled2 = function(promises) { return Promise.all( promises.map( (p) => Promise.resolve(p).then( (value) => ({ status: "fulfilled", value }), (reason) => ({ status: "rejected", reason }) ) ) ); }; document.getElementsByTagName("link"); const cspNonceMeta = document.querySelector( "meta[property=csp-nonce]" ); const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute("nonce"); promise = allSettled2( deps.map((dep) => { dep = assetsURL(dep); if (dep in seen) return; seen[dep] = true; const isCss = dep.endsWith(".css"); const cssSelector = isCss ? '[rel="stylesheet"]' : ""; if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) { return; } const link = document.createElement("link"); link.rel = isCss ? "stylesheet" : scriptRel; if (!isCss) { link.as = "script"; } link.crossOrigin = ""; link.href = dep; if (cspNonce) { link.setAttribute("nonce", cspNonce); } document.head.appendChild(link); if (isCss) { return new Promise((res, rej) => { link.addEventListener("load", res); link.addEventListener( "error", () => rej(new Error(`Unable to preload CSS for ${dep}`)) ); }); } }) ); } function handlePreloadError(err) { const e = new Event("vite:preloadError", { cancelable: true }); e.payload = err; window.dispatchEvent(e); if (!e.defaultPrevented) { throw err; } } return promise.then((res) => { for (const item of res || []) { if (item.status !== "rejected") continue; handlePreloadError(item.reason); } return baseModule().catch(handlePreloadError); }); }; const getTabsConfig = () => [ { id: "dashboard", title: t("tabDashboard"), icon: IconHouse, render: async (shadow, switchTab) => { const { renderDashboardTab: renderDashboardTab2 } = await __vitePreload(async () => { const { renderDashboardTab: renderDashboardTab3 } = await Promise.resolve().then(() => DashboardTab); return { renderDashboardTab: renderDashboardTab3 }; }, void 0 ); return renderDashboardTab2(shadow, switchTab); }, onUnmount: async () => { const { onUnmount: onUnmount2 } = await __vitePreload(async () => { const { onUnmount: onUnmount3 } = await Promise.resolve().then(() => DashboardTab); return { onUnmount: onUnmount3 }; }, void 0 ); onUnmount2(); } }, { id: "collection", title: t("tabCollection"), icon: IconStar, render: async (shadow) => { const { renderCollectionTab: renderCollectionTab2 } = await __vitePreload(async () => { const { renderCollectionTab: renderCollectionTab3 } = await Promise.resolve().then(() => CollectionTab); return { renderCollectionTab: renderCollectionTab3 }; }, void 0 ); return renderCollectionTab2(shadow); }, onUnmount: async () => { const { onUnmount: onUnmount2 } = await __vitePreload(async () => { const { onUnmount: onUnmount3 } = await Promise.resolve().then(() => CollectionTab); return { onUnmount: onUnmount3 }; }, void 0 ); onUnmount2(); }, isFullWidth: true }, { id: "data", title: t("tabData"), icon: IconDatabase, render: async (shadow, switchTab) => { const { renderDataTab: renderDataTab2 } = await __vitePreload(async () => { const { renderDataTab: renderDataTab3 } = await Promise.resolve().then(() => DataTab); return { renderDataTab: renderDataTab3 }; }, void 0 ); return renderDataTab2(shadow, switchTab); }, onUnmount: async () => { const { onUnmount: onUnmount2 } = await __vitePreload(async () => { const { onUnmount: onUnmount3 } = await Promise.resolve().then(() => DataTab); return { onUnmount: onUnmount3 }; }, void 0 ); onUnmount2(); } }, { id: "settings", title: t("tabSettings"), icon: IconSliders, render: async () => { const { renderSettingsTab: renderSettingsTab2 } = await __vitePreload(async () => { const { renderSettingsTab: renderSettingsTab3 } = await Promise.resolve().then(() => SettingsTab); return { renderSettingsTab: renderSettingsTab3 }; }, void 0 ); return renderSettingsTab2(); }, onUnmount: async () => { const { onUnmount: onUnmount2 } = await __vitePreload(async () => { const { onUnmount: onUnmount3 } = await Promise.resolve().then(() => SettingsTab); return { onUnmount: onUnmount3 }; }, void 0 ); onUnmount2(); } }, { id: "debug", title: t("labelTechnicalLogs"), icon: IconBolt, render: async () => { const { renderDebugTab: renderDebugTab2 } = await __vitePreload(async () => { const { renderDebugTab: renderDebugTab3 } = await Promise.resolve().then(() => DebugTab); return { renderDebugTab: renderDebugTab3 }; }, void 0 ); return renderDebugTab2(); }, onUnmount: async () => { const { onUnmount: onUnmount2 } = await __vitePreload(async () => { const { onUnmount: onUnmount3 } = await Promise.resolve().then(() => DebugTab); return { onUnmount: onUnmount3 }; }, void 0 ); onUnmount2(); } }, { id: "about", title: t("tabAbout"), icon: IconCircleInfo, render: async () => { const { renderAboutTab: renderAboutTab2 } = await __vitePreload(async () => { const { renderAboutTab: renderAboutTab3 } = await Promise.resolve().then(() => AboutTab); return { renderAboutTab: renderAboutTab3 }; }, void 0 ); return renderAboutTab2(); } } ]; const log$4 = Logger.scope("Settings"); const mkIcon = (svg) => UIUtils.icon(svg); const createLoadingOverlay = () => h( "div", { className: "fc2-loading-overlay" }, h("div", { className: "fc2-loading-spinner" }), h("div", { className: "fc2-loading-text" }, `${t("labelLoading")}...`) ); const createErrorState = (tabId) => h("div", { className: "fc2-error-state" }, `${t("labelError")}: ${tabId}`); const SettingsPanel = (() => { let panel = null; let shadow = null; let currentTabId = "dashboard"; let isSwitching = false; const tabCache = new Map(); const tabsConfig = getTabsConfig(); const SettingsPanelOverlay = { close: () => hide() }; const hide = (e) => { if (e) { e.preventDefault(); e.stopPropagation(); } if (!panel) return; panel.classList.add("is-hidden"); document.body.classList.remove("fc2-settings-open"); document.removeEventListener("keydown", handleKeyDown); OverlayStack.remove(SettingsPanelOverlay); CoreEvents.emit(AppEvents.PANEL_CLOSED, {}); }; const handleKeyDown = (e) => { if (!panel || panel.classList.contains("is-hidden")) return; if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); saveAndClose(); } if (e.key === "Escape") { e.preventDefault(); hide(); } }; const saveAndClose = () => { Toast.show(t("alertSettingsSaved"), "success"); hide(); }; const getContentContainer = () => shadow?.getElementById(DOM_IDS.TAB_CONTENT) ?? null; const updateActiveTabButton = (tabId) => { shadow?.querySelectorAll(".fc2-enh-tab-btn").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === tabId); }); }; const unmountTab = (tabDef) => { if (!tabDef?.onUnmount || !shadow) return; try { tabDef.onUnmount(shadow); } catch (err) { log$4.warn(`Tab unmount error: ${tabDef.id}`, err); } }; const switchTab = async (tabId) => { if (isSwitching) return; if (currentTabId === tabId && tabCache.has(tabId)) { tabCache.delete(tabId); } const oldTabDef = tabsConfig.find((tc) => tc.id === currentTabId); const tabDef = tabsConfig.find((tc) => tc.id === tabId); if (!tabDef) return; isSwitching = true; const contentContainer = getContentContainer(); if (!contentContainer) { log$4.error("Tab content container not found"); isSwitching = false; return; } try { unmountTab(oldTabDef); const oldContent = contentContainer.querySelector(".fc2-tab-content-wrapper"); if (oldContent) { oldContent.classList.add("fc2-leaving"); } updateActiveTabButton(tabId); let content = tabCache.get(tabId); if (!content) { if (oldContent) { await new Promise((r) => setTimeout(r, TIMING.UI_TRANSITION_FAST)); } contentContainer.textContent = ""; contentContainer.appendChild(createLoadingOverlay()); const element = await tabDef.render(shadow, switchTab); content = h( "div", { className: `fc2-tab-content-wrapper ${tabDef.isFullWidth ? "full-width" : ""}`, "data-tab": tabId }, element ); tabCache.set(tabId, content); } const enterDelay = oldContent && tabCache.has(tabId) ? TIMING.UI_TRANSITION_FAST : 0; setTimeout(() => { contentContainer.textContent = ""; content.classList.remove("fc2-entering", "fc2-leaving"); contentContainer.appendChild(content); requestAnimationFrame(() => { requestAnimationFrame(() => { content.classList.add("fc2-entering"); currentTabId = tabId; isSwitching = false; }); }); }, enterDelay); } catch (err) { log$4.error(`Failed to render tab: ${tabId}`, err); isSwitching = false; contentContainer.textContent = ""; contentContainer.appendChild(createErrorState(tabId)); Toast.show(`${t("labelError")}: ${tabId}`, "error"); } }; const buildTabButtons = () => tabsConfig.map( (tab) => h( "button", { className: "fc2-enh-tab-btn", "data-tab": tab.id, onclick: () => switchTab(tab.id) }, mkIcon(tab.icon), tab.title ) ); const buildPanel = () => { panel = h("div", { id: DOM_IDS.SETTINGS_HOST, className: "is-hidden" }); const container2 = h("div", { id: DOM_IDS.SETTINGS_CONTAINER }); shadow = container2.attachShadow({ mode: "open" }); shadow.appendChild(h("style", {}, getComponentStyles(Config.CLASSES))); const header = h( "div", { className: "fc2-enh-settings-header" }, h("h2", {}, t("managementCenter") || "管理中心"), h( "div", { className: "fc2-header-actions" }, h( "button", { className: "close-btn", onclick: () => hide(), title: t("btnCancel") || "Close" }, mkIcon(IconXmark) ) ) ); const sidebar = h("div", { className: "fc2-enh-settings-tabs", id: "tab-buttons" }, ...buildTabButtons()); const contentArea = h("div", { className: "fc2-enh-settings-content", id: DOM_IDS.TAB_CONTENT }); const body = h("div", { className: "fc2-enh-settings-body" }, sidebar, contentArea); const footer = h( "div", { className: "fc2-enh-settings-footer" }, h("button", { className: "fc2-enh-btn", onclick: (e) => hide(e) }, t("btnCancel")), h("button", { className: "fc2-enh-btn primary", onclick: () => saveAndClose() }, t("btnSave")) ); const panelInner = h("div", { className: "fc2-enh-settings-panel has-shadow-isolate" }, header, body, footer); const backdrop = h("div", { className: "enh-modal-backdrop", onclick: () => hide() }); shadow.append(backdrop, panelInner); panel.appendChild(container2); document.body.appendChild(panel); }; const show = (activeTabId = "dashboard") => { if (!panel) { buildPanel(); } if (panel.classList.contains("is-hidden") || currentTabId !== activeTabId) { panel.classList.remove("is-hidden"); document.body.classList.add("fc2-settings-open"); document.addEventListener("keydown", handleKeyDown); OverlayStack.push(SettingsPanelOverlay); CoreEvents.emit(AppEvents.PANEL_OPENED, {}); switchTab(activeTabId); } }; return { show, render: show, hide: () => hide(), clearCache: () => tabCache.clear(), switchTab, get currentTabId() { return currentTabId; } }; })(); CoreEvents.on(AppEvents.OPEN_SETTINGS, () => { SettingsPanel.show(); }); CoreEvents.on(AppEvents.LANGUAGE_CHANGED, () => { SettingsPanel.clearCache(); if (!document.body.classList.contains("fc2-settings-open")) return; SettingsPanel.switchTab(SettingsPanel.currentTabId); }); class DragManager { constructor(container2, trigger, onDragEndCallback) { this.documentCleanup = []; this.currentConfig = { anchorX: "right", x: 20, anchorY: "bottom", y: 40 }; this.isDragging = false; this.hasMoved = false; this.startX = 0; this.startY = 0; this.startLeft = 0; this.startTop = 0; this.onDragStart = (e) => { if ("button" in e && e.button !== 0) return; this.isDragging = true; this.hasMoved = false; const pos = this.getClientPos(e); this.startX = pos.x; this.startY = pos.y; const rect = this.container.getBoundingClientRect(); this.startLeft = rect.left; this.startTop = rect.top; this.container.style.transition = "none"; this.container.style.right = "auto"; this.container.style.bottom = "auto"; this.container.style.left = `${this.startLeft}px`; this.container.style.top = `${this.startTop}px`; this.container.style.transform = "translate3d(0,0,0)"; e.stopPropagation(); }; this.onDragMove = (e) => { if (!this.isDragging) return; if (e.cancelable) e.preventDefault(); requestAnimationFrame(() => { if (!this.isDragging) return; const pos = this.getClientPos(e); const dx = pos.x - this.startX; const dy = pos.y - this.startY; if (Math.abs(dx) > 10 || Math.abs(dy) > 10) this.hasMoved = true; const newLeft = Math.min(window.innerWidth - this.container.offsetWidth, Math.max(0, this.startLeft + dx)); const newTop = Math.min(window.innerHeight - this.container.offsetHeight, Math.max(0, this.startTop + dy)); this.container.style.left = `${newLeft}px`; this.container.style.top = `${newTop}px`; }); }; this.onDragEnd = () => { if (!this.isDragging) return; this.isDragging = false; if (this.hasMoved) { const rect = this.container.getBoundingClientRect(); const isRight = rect.left + rect.width / 2 > window.innerWidth / 2; const isBottom = rect.top + rect.height / 2 > window.innerHeight / 2; const targetMargin = 20; const targetLeftPx = isRight ? window.innerWidth - rect.width - targetMargin : targetMargin; const targetTopPx = Math.min( window.innerHeight - rect.height - targetMargin, Math.max(targetMargin, rect.top) ); this.container.style.transition = "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)"; this.container.style.left = `${targetLeftPx}px`; this.container.style.top = `${targetTopPx}px`; const config = { anchorX: isRight ? "right" : "left", x: targetMargin, anchorY: isBottom ? "bottom" : "top", y: isBottom ? window.innerHeight - targetTopPx - rect.height : targetTopPx }; if (typeof GM_setValue !== "undefined") { GM_setValue(STORAGE_KEYS.FAB_POSITION, JSON.stringify(config)); } setTimeout(() => this.applyAnchor(config), 300); if (this.onDragEndCallback) { this.onDragEndCallback(); } } else { this.applyAnchor(this.currentConfig); } }; this.container = container2; this.trigger = trigger; this.onDragEndCallback = onDragEndCallback; this.init(); } init() { const savedConfig = typeof GM_getValue !== "undefined" ? GM_getValue(STORAGE_KEYS.FAB_POSITION) : null; if (savedConfig) { try { this.applyAnchor(JSON.parse(savedConfig)); } catch { this.applyAnchor(this.currentConfig); } } else { this.applyAnchor(this.currentConfig); } this.trigger.addEventListener("mousedown", this.onDragStart); this.trigger.addEventListener("touchstart", this.onDragStart, { passive: false }); const docListeners = [ ["mousemove", this.onDragMove, { passive: false }], ["touchmove", this.onDragMove, { passive: false }], ["mouseup", this.onDragEnd], ["touchend", this.onDragEnd] ]; for (const [event, handler, options] of docListeners) { document.addEventListener(event, handler, options); this.documentCleanup.push(() => document.removeEventListener(event, handler, options)); } const onResize = () => this.applyAnchor(this.currentConfig); window.addEventListener("resize", onResize); this.documentCleanup.push(() => window.removeEventListener("resize", onResize)); } applyAnchor(config) { this.currentConfig = config; this.container.style.transition = "none"; this.container.style.transform = "translate3d(0,0,0)"; this.container.style.left = config.anchorX === "left" ? `${config.x}px` : "auto"; this.container.style.right = config.anchorX === "right" ? `${config.x}px` : "auto"; this.container.style.top = config.anchorY === "top" ? `${config.y}px` : "auto"; this.container.style.bottom = config.anchorY === "bottom" ? `${config.y}px` : "auto"; } getClientPos(e) { if ("touches" in e && e.touches.length > 0) { const touch = e.touches[0]; return touch ? { x: touch.clientX, y: touch.clientY } : { x: 0, y: 0 }; } const mEvent = e; return { x: mEvent.clientX, y: mEvent.clientY }; } destroy() { this.documentCleanup.forEach((fn) => fn()); this.documentCleanup = []; this.trigger.removeEventListener("mousedown", this.onDragStart); this.trigger.removeEventListener("touchstart", this.onDragStart); } } const log$3 = Logger.scope("QuickBar"); class QuickBarService { constructor() { this.container = null; this.documentCleanup = []; } async onInit() { log$3.debug("Initializing FAB interface"); this.render(); this.bindGlobalEvents(); } bindGlobalEvents() { CoreEvents.on(AppEvents.LANGUAGE_CHANGED, () => { MenuService.register(); this.render(); }); CoreEvents.on(AppEvents.PANEL_OPENED, () => { if (this.container) this.container.style.display = "none"; }); CoreEvents.on(AppEvents.PANEL_CLOSED, () => { if (this.container) this.container.style.display = ""; }); } render() { this.documentCleanup.forEach((fn) => fn()); this.documentCleanup = []; if (this.container) this.container.remove(); if (!State.proxy.enableQuickBar) return; const appState = State.proxy; this.container = h("div", { className: "fc2-fab-container" }); const actions = h("div", { className: "fc2-fab-actions" }); const mkBtn = (iconSvg, title, prop, onClick) => { const iconContainer = UIUtils.icon(iconSvg); const b = h( "button", { className: `fc2-fab-btn ${prop && appState[prop] ? "active" : ""}`, "data-title": title, "aria-label": title, ...prop ? { "data-prop": prop } : {}, onclick: (e) => { e.preventDefault(); e.stopPropagation(); if (prop) { appState[prop] = !appState[prop]; } else if (onClick) onClick(); } }, iconContainer ); return b; }; actions.appendChild( mkBtn(IconRotate, t("labelSyncing"), null, () => { SyncService.onProgress = (progress) => Toast.show(`${progress.phase} (${progress.percent}%)`, "info"); SyncService.performSync(true).catch(() => { }); }) ); actions.appendChild( mkBtn(IconStar, t("tabCollection"), null, () => { SettingsPanel.show("collection"); }) ); actions.appendChild(mkBtn(IconEyeSlash, t("optionHideViewed"), "hideViewed")); actions.appendChild(mkBtn(IconMagnet, t("optionHideNoMagnet"), "hideNoMagnet")); actions.appendChild(mkBtn(IconBan, t("optionHideCensored"), "hideCensored")); actions.appendChild(mkBtn(IconGear, t("tabSettings"), null, SettingsPanel.show)); actions.appendChild( mkBtn(IconArrowUp, t("btnBackToTop"), null, () => window.scrollTo({ top: 0, behavior: "smooth" })) ); const trigger = h( "button", { className: "fc2-fab-trigger", "aria-label": t("btnMoreOptions") }, UIUtils.icon(IconPlus), h("div", { className: `fc2-sync-dot ${appState.syncStatus}`, style: { display: appState.syncMode === "none" || appState.syncStatus === "idle" ? "none" : "block" } }) ); State.on(({ prop, value: val }) => { const dot = trigger.querySelector(".fc2-sync-dot"); if (dot) { if (prop === "syncStatus") { dot.className = `fc2-sync-dot ${val}`; dot.style.display = State.proxy.syncMode === "none" || val === "idle" ? "none" : "block"; } else if (prop === "syncMode") { dot.style.display = val === "none" || State.proxy.syncStatus === "idle" ? "none" : "block"; } } if (["hideViewed", "hideNoMagnet", "hideCensored"].includes(prop)) { const btn = actions.querySelector(`[data-prop="${prop}"]`); if (btn) { btn.classList.toggle("active", !!val); } } }); this.container.append(actions, trigger); const shadowWrapper = h("div", { className: "fc2-quickbar-host" }, this.container); UIHost.add(shadowWrapper); this.initDraggable(this.container, trigger, actions); } initDraggable(container2, trigger, actions) { const dragManager = new DragManager(container2, trigger); trigger.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); if (!dragManager.hasMoved) { const vis = actions.classList.toggle("visible"); trigger.classList.toggle("active", vis); } }); const closeFAB = (e) => { if (!e.composedPath().includes(container2) && actions.classList.contains("visible")) { actions.classList.remove("visible"); trigger.classList.remove("active"); } }; document.addEventListener("click", closeFAB, true); this.documentCleanup.push(() => { document.removeEventListener("click", closeFAB, true); dragManager.destroy(); }); } } AppContainer.register("quickbar", new QuickBarService()); const log$2 = Logger.scope("Main"); const isCloudflareChallenge = () => { return document.title.includes("Just a moment...") || !!document.querySelector("#challenge-running") || !!document.querySelector('iframe[src*="challenges.cloudflare.com"]'); }; const main = async () => { Logger.init(); if (isCloudflareChallenge()) { log$2.warn("Cloudflare challenge detected, skipping initialization"); return; } try { Logger.group("Main", `${SCRIPT_INFO.NAME} bootstrap`); await AppContainer.bootstrap(); CoreEvents.emit(AppEvents.UI_READY, {}); log$2.info("System initialized"); Logger.groupEnd(); } catch (error) { log$2.error("Bootstrap failed", error); } }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", main); } else { main(); } const Checkbox = (props) => { const { id, label, checked, onChange, controller, binding } = props; const checkbox = h("input", { id: `set-${id}`, type: "checkbox", checked: checked || false, onchange: !controller || !binding ? (e) => { const isChecked = e.target.checked; if (onChange) onChange(isChecked); } : void 0 }); const container2 = h( "label", { className: "fc2-enh-checkbox-label", htmlFor: `set-${id}` }, checkbox, h("span", { className: "fc2-enh-checkbox-text" }, label) ); if (controller && binding) { controller.bind(checkbox, { key: binding, mode: "checked" }); } return container2; }; const FormRow = (props) => { const { label, children, className = "" } = props; const childArray = Array.isArray(children) ? children : [children]; return h( "div", { className: `fc2-enh-form-row ${className}` }, label ? h("label", { className: "fc2-enh-label" }, label) : null, ...childArray ); }; const CheckboxRow = (props) => { const { className = "" } = props; const checkboxLabel = Checkbox(props); checkboxLabel.className = `${checkboxLabel.className} fc2-enh-form-row checkbox ${className}`; return checkboxLabel; }; const resolveIcon$1 = (icon) => typeof icon === "string" ? UIUtils.icon(icon) : icon; const Button = (props) => { const { text, onClick, className = "", icon, title, disabled, style } = props; const btn = h( "button", { className: `fc2-enh-btn ${className}`.trim(), title: title || "", disabled: !!disabled, onclick: (e) => { const result = onClick(e); if (result instanceof Promise) { result.catch((err) => Logger.error("UI", "Button action error", err)); } } }, icon ? resolveIcon$1(icon) : null, text ? h("span", { className: "fc2-btn-text" }, text) : null ); if (style) { Object.assign(btn.style, style); } return btn; }; const Input = (props) => { const { id, type, value, placeholder, validator, onChange, controller, binding } = props; const input = h("input", { id: `set-${id}`, className: "fc2-enh-input", type: type === "number" ? "text" : type, inputMode: type === "number" ? "numeric" : void 0, pattern: type === "number" ? "[0-9]*" : void 0, value: value || "", placeholder: placeholder || "", oninput: (e) => { const target = e.target; const newValue = target.value; if (validator) { const result = validator(newValue); if (!result.valid) { target.classList.add("invalid"); target.title = result.error || "Invalid input"; } else { target.classList.remove("invalid"); target.title = ""; } } if (!controller || !binding) { if (onChange) { onChange(newValue); } } }, autocomplete: "off", spellcheck: false }); if (controller && binding) { controller.bind(input, { key: binding, mode: "value", onChange: onChange ? (v) => onChange(v) : void 0 }); } return input; }; const Select = (props) => { const { id, options, value, onChange, controller, binding } = props; const select = h( "select", { id: `set-${id}`, className: "fc2-enh-select", onchange: !controller || !binding ? (e) => { const newValue = e.target.value; if (onChange) { onChange(newValue); } } : void 0 }, ...options.map( (opt) => h( "option", { value: opt.value, selected: value !== void 0 ? opt.value === value : false }, opt.label ) ) ); if (controller && binding) { controller.bind(select, { key: binding, mode: "value", onChange: onChange ? (v) => onChange(v) : void 0 }); } return select; }; const resolveIcon = (icon) => typeof icon === "string" ? UIUtils.icon(icon) : icon; const Card = (props) => { const { title, subtitle, icon, children, className = "" } = props; const childArray = Array.isArray(children) ? children : [children]; return h( "div", { className: `fc2-dashboard-card ${className}`.trim() }, h( "h4", {}, icon ? resolveIcon(icon) : null, h("span", { className: "fc2-card-title" }, title), subtitle ? h("div", { className: "fc2-card-subtitle" }, subtitle) : null ), ...childArray ); }; const LEDIndicator = (props) => { const { id, active = false, label = "", color } = props; const led = h("span", { id: id ? `led-${id}` : void 0, className: `fc2-led-dot ${active ? "active" : ""}`.trim(), ...color ? { style: { background: color } } : {} }); return h( "div", { className: "fc2-led-group" }, led, h("span", { className: "fc2-led-label", id: id ? `led-label-${id}` : void 0 }, label) ); }; const StatItem = (props) => { const { id, label, value, unit } = props; const valContainer = h("div", { className: "fc2-stat-value", id: id ? `stat-${id}` : void 0 }); if (value instanceof HTMLElement) { valContainer.appendChild(value); } else { valContainer.textContent = String(value); } if (unit) { valContainer.appendChild(h("span", { className: "fc2-stat-unit" }, unit)); } return h("div", { className: "fc2-stat-item" }, valContainer, h("div", { className: "fc2-stat-label" }, label)); }; const renderToggleIcon = (isPassword) => UIUtils.icon(isPassword ? IconEye : IconEyeSlash); const PasswordInput = (props) => { const input = Input({ ...props, type: "password" }); const toggle = h("button", { className: "fc2-input-toggle", type: "button", onclick: () => { const isPass = input.type === "password"; input.type = isPass ? "text" : "password"; toggle.textContent = ""; toggle.appendChild(renderToggleIcon(!isPass)); } }); toggle.appendChild(renderToggleIcon(true)); return h("div", { className: "fc2-input-group" }, input, toggle); }; const log$1 = Logger.scope("Reactive"); class ReactiveController { constructor() { this.bindings = new Map(); this.cleanups = []; this.disposed = false; const unbind = State.on(({ prop, value }) => { if (this.disposed) return; this.bindings.forEach((options, el) => { if (options.key === prop) { this.updateElement(el, options, value); } }); }); this.cleanups.push(unbind); } get isDisposed() { return this.disposed; } bind(el, options) { if (this.disposed) { log$1.warn("Attempted to bind on a disposed controller"); return; } this.bindings.set(el, options); const currentValue = State.proxy[options.key]; this.updateElement(el, options, currentValue); const inputEl = el; const isTextControl = el.tagName === "TEXTAREA" || el.tagName === "INPUT" && ["text", "password", "url", "email", "number", "range"].includes(el.type); const eventType = isTextControl ? "input" : "change"; const listener = () => { if (this.disposed) return; let newValue; if (options.mode === "checked") { newValue = el.checked; } else if (options.mode === "toggle") { newValue = !State.proxy[options.key]; } else { const raw = inputEl.value; newValue = typeof State.proxy[options.key] === "number" ? Number(raw) : raw; } State.proxy[options.key] = newValue; if (options.onChange) options.onChange(newValue); }; el.addEventListener(eventType, listener); this.cleanups.push(() => el.removeEventListener(eventType, listener)); } updateElement(el, options, value) { const inputEl = el; switch (options.mode) { case "checked": el.checked = !!value; break; case "value": inputEl.value = String(value ?? ""); break; case "text": el.textContent = String(value ?? ""); break; case "html": el.textContent = String(value ?? ""); break; default: inputEl.value = String(value ?? ""); } } bindList(container2, options) { if (this.disposed) return; const render = () => { const list = State.proxy[options.key] || []; container2.textContent = ""; list.forEach((item, index) => { container2.appendChild(options.renderItem(item, index)); }); }; const unbind = State.on(({ prop }) => { if (this.disposed) return; if (prop === options.key) render(); }); this.cleanups.push(unbind); render(); } listen(key, callback) { if (this.disposed) return; const unbind = State.on(({ prop, value }) => { if (this.disposed) return; if (prop === key) callback(value); }); this.cleanups.push(unbind); callback(State.proxy[key]); } dispose() { if (this.disposed) return; this.disposed = true; this.cleanups.forEach((fn) => fn()); this.cleanups = []; this.bindings.clear(); } static create() { return new ReactiveController(); } } class TabLifecycle { constructor() { this.controller = null; this.cleanups = []; } reset() { this.dispose(); this.controller = ReactiveController.create(); return this.controller; } get ctrl() { if (!this.controller) { throw new Error("TabLifecycle: reset() must be called before accessing ctrl"); } return this.controller; } addCleanup(fn) { this.cleanups.push(fn); } dispose() { if (this.controller) { this.controller.dispose(); this.controller = null; } this.cleanups.forEach((fn) => fn()); this.cleanups = []; } } const lifecycle$2 = new TabLifecycle(); const renderDashboardTab = (_shadow, switchTab) => { lifecycle$2.reset(); const mkIcon2 = (svg) => UIUtils.icon(svg); const stats = { itemCount: h("span", { textContent: "..." }), cacheSize: h("span", { textContent: "..." }), syncDetails: h("div", { className: "fc2-stat-label fc2-mt-sm" }) }; const updateStats = async () => { const items = await CollectionService.getCollectionItems(); stats.itemCount.textContent = String(items.length); const cache = await Repository.cache.getAll(); stats.cacheSize.textContent = String(cache.length); }; const syncLed = LEDIndicator({ id: "dashboard-sync", active: false, label: "" }); const syncLabel = syncLed.querySelector(".fc2-led-label"); const ledDot = syncLed.querySelector(".fc2-led-dot"); const updateSyncUI = () => { const mode = State.proxy.syncMode; if (mode === "none") { if (syncLabel) syncLabel.textContent = t("dashSyncModeNone"); if (ledDot) { ledDot.classList.remove("active"); ledDot.style.background = "#666"; } stats.syncDetails.textContent = t("dashSyncSuggestWebDAV"); } else { if (syncLabel) syncLabel.textContent = mode.toUpperCase(); if (ledDot) { ledDot.classList.add("active"); ledDot.style.background = ""; } stats.syncDetails.textContent = `${t("syncStatus")}: ${mode.toUpperCase()} | NODE_OK`; } }; lifecycle$2.addCleanup( CoreEvents.on(AppEvents.STATE_CHANGED, ({ prop }) => { if (prop === "syncMode") updateSyncUI(); }) ); lifecycle$2.addCleanup(CoreEvents.on(AppEvents.COLLECTION_UPDATED, updateStats)); const container2 = h( "div", { className: "fc2-dashboard-container" }, h( "div", { className: "fc2-dashboard-grid" }, Card({ title: t("dashCollStatus"), icon: mkIcon2(IconStar), children: [ StatItem({ label: t("dashCollCountLabel"), value: stats.itemCount }), h( "div", { className: "fc2-card-actions" }, Button({ text: t("dashEnterColl"), className: "primary", style: { width: "100%" }, onClick: () => switchTab("collection") }) ) ] }), Card({ title: t("dashSyncTitle"), icon: mkIcon2(IconDatabase), children: [ syncLed, stats.syncDetails, h( "div", { className: "fc2-card-actions" }, Button({ text: t("dashSyncConsole"), style: { width: "100%" }, onClick: () => switchTab("data") }) ) ] }), Card({ title: t("dashHealthTitle"), icon: mkIcon2(IconBolt), children: [ StatItem({ label: t("dashCacheSizeLabel"), value: stats.cacheSize }), h( "div", { className: "fc2-card-actions fc2-gap-sm" }, Button({ text: t("dashRunRepair"), className: "danger", style: { flex: "1" }, onClick: async () => { if (confirm(t("dashRepairConfirm"))) { await CollectionService.runHealthCheck(true); updateStats(); } } }), Button({ text: t("dashViewReport"), style: { flex: "1" }, onClick: () => switchTab("debug") }) ) ] }) ) ); updateSyncUI(); updateStats(); return container2; }; const onUnmount$4 = () => { lifecycle$2.dispose(); }; const DashboardTab = Object.freeze( Object.defineProperty({ __proto__: null, onUnmount: onUnmount$4, renderDashboardTab }, Symbol.toStringTag, { value: "Module" })); class SearchIndex { constructor() { this.index = new Map(); this.items = new Map(); } build(items, fields) { this.index.clear(); this.items.clear(); items.forEach((item) => { const id = item.id || item.code || item.work_id; if (!id) return; this.items.set(id, item); const keywords = new Set(); fields.forEach((field) => { const value = item[field]; if (value) { this.tokenize(String(value)).forEach((token) => keywords.add(token)); } }); keywords.forEach((keyword) => { if (!this.index.has(keyword)) { this.index.set(keyword, new Set()); } this.index.get(keyword).add(id); }); }); } search(query) { if (!query || !query.trim()) return Array.from(this.items.values()); const tokens2 = this.tokenize(query); if (tokens2.length === 0) return Array.from(this.items.values()); let resultIds = null; for (const token of tokens2) { const matches = new Set(); for (const [keyword, ids] of this.index.entries()) { if (keyword.includes(token)) { ids.forEach((id) => matches.add(id)); } } if (resultIds === null) { resultIds = matches; } else { const nextResult = new Set(); matches.forEach((id) => { if (resultIds.has(id)) nextResult.add(id); }); resultIds = nextResult; } if (resultIds.size === 0) break; } return resultIds ? Array.from(resultIds).map((id) => this.items.get(id)) : []; } tokenize(text) { return text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").split(/[\s\-_./\\()[\]]+/).filter((t2) => t2.length > 0); } } const log = Logger.scope("CollectionTab"); let activeObserver = null; let currentUnbinds = []; const clearChildren = (el) => { el.textContent = ""; }; const buildNoResults = () => h( "div", { className: "fc2-no-results" }, h("div", { className: "icon" }, UIUtils.icon(IconCircleInfo)), h("div", { className: "text" }, t("alertNoPreview")) ); const formatDate = (ts) => { if (!ts) return "—"; return new Date(ts).toLocaleDateString(); }; const renderCollectionTab = (shadow) => { onUnmount$3(); let allItems = []; let filteredItems = []; let collectionStats = null; const duplicateKeys = new Set(); const searchIndex = new SearchIndex(); const collState = { searchQuery: "", sort: "date-desc", site: "all", folder: "all", isBatchMode: false, selectedIds: new Set(), activeTags: new Set() }; const container2 = h("div", { className: "fc2-collection-container" }); const gallery = h("div", { className: "fc2-collection-grid" }); const statsDisplay = h("span", { className: "fc2-label-dim fc2-ml-sm" }); const healthProgress = h("div", { className: "fc2-health-progress is-hidden" }); const statsHeader = h("div", { className: "fc2-collection-stats-header is-hidden" }); const renderStatsHeader = (stats) => { clearChildren(statsHeader); statsHeader.classList.remove("is-hidden"); const statItems = [ { label: t("collTotal"), value: String(stats.totalItems), icon: IconStar }, { label: t("collStatsRated"), value: stats.ratedCount > 0 ? `${stats.ratedCount} (${t("collStatsAvg")}: ${stats.averageRating.toFixed(1)}★)` : "0", icon: IconStar }, { label: t("collStatsWithNotes"), value: String(stats.withNotes), icon: IconCircleInfo }, { label: t("collStatsWithTags"), value: String(stats.withTags), icon: IconCircleInfo } ]; const grid = h("div", { className: "fc2-stats-grid" }); statItems.forEach(({ label, value }) => { grid.appendChild( h( "div", { className: "fc2-stat-chip" }, h("span", { className: "label" }, label), h("span", { className: "value" }, value) ) ); }); statsHeader.appendChild(grid); if (stats.oldestItem && stats.newestItem) { statsHeader.appendChild( h( "div", { className: "fc2-stats-date-range" }, `${formatDate(stats.oldestItem)} — ${formatDate(stats.newestItem)}` ) ); } }; const tagCloud = h("div", { className: "fc2-tag-cloud is-hidden" }); const renderTagCloud = async () => { const tags = await CollectionService.getAllTags(); clearChildren(tagCloud); if (tags.length === 0) { tagCloud.classList.add("is-hidden"); return; } tagCloud.classList.remove("is-hidden"); const label = h("span", { className: "fc2-tag-cloud-label" }, `${t("labelUserTags")}:`); tagCloud.appendChild(label); const maxCount = tags.reduce((max, t2) => Math.max(max, t2.count), 0); tags.slice(0, 20).forEach(({ tag, count }) => { const isActive = collState.activeTags.has(tag); const sizeClass = count >= maxCount * 0.7 ? "large" : count >= maxCount * 0.3 ? "medium" : "small"; const chip = h( "button", { className: `fc2-tag-cloud-item ${sizeClass} ${isActive ? "active" : ""}`, onclick: () => { if (collState.activeTags.has(tag)) { collState.activeTags.delete(tag); } else { collState.activeTags.add(tag); } chip.classList.toggle("active", collState.activeTags.has(tag)); applyFilters(); } }, `${tag}`, h("span", { className: "fc2-tag-count" }, `${count}`) ); tagCloud.appendChild(chip); }); if (collState.activeTags.size > 0) { tagCloud.appendChild( h( "button", { className: "fc2-tag-cloud-clear", onclick: () => { collState.activeTags.clear(); renderTagCloud(); applyFilters(); } }, `✕ ${t("btnDeselectAll")}` ) ); } }; const backToTop = h( "button", { className: "fc2-back-to-top is-hidden", onclick: () => { const body = shadow.querySelector(".fc2-enh-settings-content"); if (body) body.scrollTo({ top: 0, behavior: "smooth" }); } }, UIUtils.icon(IconChevronUp) ); const applyFilters = () => { const results = searchIndex.search(collState.searchQuery); filteredItems = results.filter((item) => { const matchesSite = collState.site === "all" || (item.type || "FC2").toLowerCase() === collState.site; const matchesFolder = collState.folder === "all" || (item.folder || "wanted") === collState.folder; if (collState.sort === "rating" && !item.rating) return false; if (collState.sort === "notes" && !item.notes) return false; if (collState.activeTags.size > 0) { const itemTags = new Set(item.userTags || []); const hasMatchingTag = Array.from(collState.activeTags).some((t2) => itemTags.has(t2)); if (!hasMatchingTag) return false; } return matchesSite && matchesFolder; }); filteredItems.sort((a, b) => { switch (collState.sort) { case "date-desc": return (b.lastAccessed || 0) - (a.lastAccessed || 0); case "date-asc": return (a.lastAccessed || 0) - (b.lastAccessed || 0); case "title": return (a.title || "").localeCompare(b.title || ""); case "site": return (a.type || "").localeCompare(b.type || ""); case "rating": return (b.rating || 0) - (a.rating || 0); case "folder": return (a.folder || "wanted").localeCompare(b.folder || "wanted"); default: return 0; } }); statsDisplay.textContent = `${t("collTotal")}: ${allItems.length} | ${t("collShown")}: ${filteredItems.length}`; renderGallery(); }; const debouncedFilter = Utils.debounce(applyFilters, 200); let itemsToShow = 30; const CHUNK_SIZE = 30; const renderGallery = (append = false) => { if (!append) { clearChildren(gallery); itemsToShow = CHUNK_SIZE; if (activeObserver) activeObserver.disconnect(); } const itemsToRender = filteredItems.slice(append ? itemsToShow - CHUNK_SIZE : 0, itemsToShow); if (filteredItems.length === 0) { gallery.appendChild(buildNoResults()); return; } itemsToRender.forEach((item) => { const { finalElement } = EnhancedCard(item, () => { }, { skipFilters: true, minimal: false }); const card2 = finalElement; card2.classList.add(Config.CLASSES.cardRebuilt); card2.removeAttribute("data-enh-searching"); card2.classList.toggle("is-selected", collState.selectedIds.has(item.id)); card2.onclick = (e) => { if (collState.isBatchMode) { e.preventDefault(); if (collState.selectedIds.has(item.id)) collState.selectedIds.delete(item.id); else collState.selectedIds.add(item.id); card2.classList.toggle("is-selected", collState.selectedIds.has(item.id)); updateBatchBar(); } }; const removeIcon = h( "div", { className: `fc2-card-remove-overlay ${collState.isBatchMode ? "is-hidden" : ""}`.trim(), onclick: async (e) => { e.stopPropagation(); if (!confirm(t("confirmDelete"))) return; const prev = { ...item }; await CollectionService.remove(item.id); Toast.show(t("collRemoveSuccess"), "info", { action: { label: t("collUndo"), onClick: () => CollectionService.add(prev.id, prev) } }); } }, "×" ); if (item.folder && item.folder !== "wanted") { const folderBadge = h("div", { className: "fc2-card-folder-badge" }, item.folder); card2.appendChild(folderBadge); } if (item.rating && item.rating > 0) { const stars = h("div", { className: "fc2-card-rating-indicator" }, "★".repeat(item.rating)); card2.appendChild(stars); } card2.appendChild(removeIcon); gallery.appendChild(card2); }); if (itemsToShow < filteredItems.length) { const sentinel = h("div", { className: "fc2-gallery-sentinel" }); gallery.appendChild(sentinel); activeObserver = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry && entry.isIntersecting) { activeObserver?.disconnect(); requestAnimationFrame(() => { itemsToShow += CHUNK_SIZE; renderGallery(true); }); } }, { rootMargin: "400px" } ); activeObserver.observe(sentinel); } }; const batchBar = h("div", { className: "fc2-batch-action-bar is-hidden" }); let availableFolders = []; const updateBatchBar = () => { if (collState.isBatchMode) { batchBar.classList.remove("is-hidden"); } else { batchBar.classList.add("is-hidden"); } clearChildren(batchBar); if (!collState.isBatchMode) return; const count = collState.selectedIds.size; batchBar.append( h( "div", { className: "batch-info" }, h("span", { className: "count" }, t("collSelected", { count })), Button({ text: count === filteredItems.length ? t("btnDeselectAll") : t("btnSelectAll"), className: "ghost micro", onClick: () => { if (count === filteredItems.length) { collState.selectedIds.clear(); } else { filteredItems.forEach((item) => collState.selectedIds.add(item.id)); } updateBatchBar(); renderGallery(); } }) ), h( "div", { className: "batch-actions" }, Button({ text: t("collBatchMove"), className: "secondary", disabled: count === 0, onClick: async () => { const target = prompt(t("collMoveTarget", { folders: availableFolders.join(", ") })); if (target) { const result = await CollectionService.batchMoveToFolder( Array.from(collState.selectedIds), target ); collState.selectedIds.clear(); collState.isBatchMode = false; loadData(); if (result.success) { Toast.show(t("collMoveSuccess", { count, target }), "success"); } } } }), Button({ text: t("collBatchMerge"), className: "secondary", disabled: count < 2, onClick: async () => { if (confirm(t("collBatchMerge") + "?")) { const ids = Array.from(collState.selectedIds); const master = ids[0]; if (!master) return; const dupes = ids.slice(1); const result = await CollectionService.mergeItems(master, dupes); collState.selectedIds.clear(); collState.isBatchMode = false; loadData(); if (result.success) { Toast.show(t("alertMetadataSaved"), "success"); } } } }), Button({ text: t("collBatchRemove"), className: "danger", disabled: count === 0, onClick: async () => { if (confirm(t("confirmDeleteSelected", { count }))) { const result = await CollectionService.batchRemove(Array.from(collState.selectedIds)); collState.selectedIds.clear(); collState.isBatchMode = false; updateBatchBar(); loadData(); if (result.success) { Toast.show(t("collBatchRemoveSuccess"), "success"); } } } }), Button({ text: t("btnCancel"), onClick: () => { collState.isBatchMode = false; collState.selectedIds.clear(); updateBatchBar(); renderGallery(); } }) ) ); }; const searchInput = h("input", { className: "fc2-enh-input", placeholder: t("searchPlaceholder"), value: collState.searchQuery, oninput: (e) => { collState.searchQuery = e.target.value; if (collState.searchQuery) { clearBtn.classList.remove("is-invisible"); } else { clearBtn.classList.add("is-invisible"); } debouncedFilter(); } }); const clearBtn = h( "button", { className: "fc2-search-clear is-invisible", onclick: () => { collState.searchQuery = ""; searchInput.value = ""; clearBtn.classList.add("is-invisible"); applyFilters(); } }, "×" ); const folderSelectContainer = h("div", { className: "fc2-select-container" }); const renderFolderSelect = () => { clearChildren(folderSelectContainer); folderSelectContainer.appendChild( Select({ id: "coll-folder", value: collState.folder, options: [ { label: t("collFolderAll"), value: "all" }, ...availableFolders.map((f) => ({ label: f === "wanted" ? t("folderWanted") : f === "viewed" ? t("folderViewed") : f === "follow" ? t("folderFollow") : f, value: f })) ], onChange: (v) => { collState.folder = v; applyFilters(); } }) ); }; const handleExport = async () => { try { const exportData = await CollectionService.exportCollection(); const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `fc2ppvdb-collection-${( new Date()).toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); Toast.show(t("alertExportSuccess"), "success"); } catch (err) { log.error("Export failed", err); Toast.show(t("labelError"), "error"); } }; const handleImport = () => { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); const result = await CollectionService.importCollection(data); if (result.success) { Toast.show(t("collImportSuccess", { count: result.affected }), "success"); } else { Toast.show( `${t("collImportPartial", { count: result.affected, errors: result.errors.length })}`, "warn" ); } loadData(); } catch { Toast.show(t("alertImportError"), "error"); } }; input.click(); }; const toolbar = h( "div", { className: "fc2-collection-toolbar" }, h( "div", { className: "toolbar-group search" }, UIUtils.icon(IconMagnifyingGlass), h("div", { className: "input-wrapper" }, searchInput, clearBtn), statsDisplay ), h( "div", { className: "toolbar-group filters" }, Select({ id: "coll-site", value: collState.site, options: [ { label: t("collSiteAll"), value: "all" }, ...PortalService.getAllSites().map((s) => ({ label: s.toUpperCase(), value: s.toLowerCase() })) ], onChange: (v) => { collState.site = v; applyFilters(); } }), folderSelectContainer ), h( "div", { className: "toolbar-group actions" }, healthProgress, Select({ id: "coll-sort", value: collState.sort, options: [ { label: t("collSortNewest"), value: "date-desc" }, { label: t("collSortOldest"), value: "date-asc" }, { label: t("collSortTitle"), value: "title" }, { label: t("collSortFolder"), value: "folder" }, { label: t("labelRating"), value: "rating" } ], onChange: (v) => { collState.sort = v; applyFilters(); } }), Button({ text: t("collBatchMode"), className: collState.isBatchMode ? "active" : "", onClick: () => { collState.isBatchMode = !collState.isBatchMode; updateBatchBar(); renderGallery(); } }), Button({ text: "", icon: UIUtils.icon(IconClockRotateLeft), className: "icon-only", title: t("dashRunRepair"), onClick: async () => { const result = await CollectionService.runHealthCheck(true); Toast.show( t("collHealthResult", { checked: result.checked, repaired: result.repaired }), result.repaired > 0 ? "success" : "info" ); loadData(); } }), Button({ text: "", icon: UIUtils.icon(IconDatabase), className: "icon-only", title: t("collExportJson"), onClick: handleExport }), Button({ text: "", icon: UIUtils.icon(IconRotate), className: "icon-only", title: t("collImportJson"), onClick: handleImport }) ) ); const folderBar = h("div", { className: "fc2-folder-management-bar" }); const renderFolderBar = () => { clearChildren(folderBar); const currentFolder = collState.folder; const isSystemFolder = currentFolder === "all" || SYSTEM_FOLDERS.includes(currentFolder); const actions = h("div", { className: "fc2-folder-actions" }); actions.appendChild( Button({ text: t("collNewFolder"), className: "ghost micro", onClick: () => { const name = prompt(t("promptFolderName")); if (name && name.trim()) { if (!availableFolders.includes(name.trim())) { availableFolders.push(name.trim()); renderFolderSelect(); } collState.folder = name.trim(); applyFilters(); } } }) ); if (!isSystemFolder && currentFolder !== "all") { actions.appendChild( Button({ text: t("collRenameFolder"), className: "ghost micro", onClick: async () => { const newName = prompt(t("promptNewFolderName")); if (newName && newName.trim()) { const result = await CollectionService.renameFolder(currentFolder, newName.trim()); if (result.success) { Toast.show(t("alertFolderRenamed"), "success"); collState.folder = newName.trim(); loadData(); } } } }) ); actions.appendChild( Button({ text: t("collDeleteFolder"), className: "ghost micro danger", onClick: async () => { if (confirm(t("confirmDeleteFolder", { folder: currentFolder }))) { const result = await CollectionService.deleteFolder(currentFolder); if (result.success) { collState.folder = "all"; loadData(); } } } }) ); } if (CollectionService.canUndo()) { const undoLabel = CollectionService.getUndoLabel(); actions.appendChild( Button({ text: `${t("collUndo")}${undoLabel ? `: ${undoLabel}` : ""}`, className: "ghost micro", onClick: async () => { const ok = await CollectionService.undo(); if (ok) { Toast.show(t("collUndo"), "success"); loadData(); } } }) ); } folderBar.appendChild(actions); }; const loadData = async (e) => { allItems = await CollectionService.getCollectionItems(); availableFolders = await CollectionService.getFolders(); collectionStats = await CollectionService.getStats(); renderFolderSelect(); renderFolderBar(); if (collectionStats) { renderStatsHeader(collectionStats); } renderTagCloud(); const isStructural = !e || !["metadata", "move"].includes(e.type || ""); if (isStructural) { searchIndex.build(allItems, ["id", "title", "userTags", "notes", "site", "code"]); const dupes = await CollectionService.findDuplicates(); duplicateKeys.clear(); dupes.forEach((g) => { const item = g[0]; if (!item) return; const m = item.id.match(/\d{5,8}/); duplicateKeys.add(m && m[0] ? `fc2-${m[0]}` : item.id.toLowerCase()); }); } applyFilters(); }; currentUnbinds.push(CoreEvents.on(AppEvents.COLLECTION_UPDATED, loadData)); currentUnbinds.push( CoreEvents.on(AppEvents.COLLECTION_HEALTH_PROGRESS, (data) => { healthProgress.classList.remove("is-hidden"); clearChildren(healthProgress); healthProgress.append(UIUtils.icon(IconBolt), ` Repairing: ${data.processed}/${data.total}`); if (data.processed === data.total) setTimeout(() => healthProgress.classList.add("is-hidden"), 3e3); }) ); const scrollListener = (e) => { const target = e.target; if (target.scrollTop > 500) { backToTop.classList.remove("is-hidden"); } else { backToTop.classList.add("is-hidden"); } }; setTimeout(() => { const content = shadow.querySelector(".fc2-enh-settings-content"); if (content) { content.addEventListener("scroll", scrollListener); currentUnbinds.push(() => content.removeEventListener("scroll", scrollListener)); } }, 100); container2.append(statsHeader, toolbar, tagCloud, folderBar, batchBar, gallery, backToTop); loadData(); return container2; }; const onUnmount$3 = () => { if (activeObserver) { activeObserver.disconnect(); activeObserver = null; } currentUnbinds.forEach((fn) => fn()); currentUnbinds = []; }; const CollectionTab = Object.freeze( Object.defineProperty({ __proto__: null, onUnmount: onUnmount$3, renderCollectionTab }, Symbol.toStringTag, { value: "Module" })); const lifecycle$1 = new TabLifecycle(); const buildStorageSection = (render) => Card({ title: t("groupDataManagement"), icon: IconDatabase, children: [ h( "div", { className: "fc2-grid-actions" }, Button({ text: t("btnClearCache"), className: "danger ghost", onClick: async () => { if (confirm(t("confirmResetDatabase")) && confirm(t("confirmDestructiveAction"))) { await Repository.cache.clear(); Toast.show(t("alertCacheCleared"), "success"); } } }), Button({ text: t("btnClearHistory"), className: "danger ghost", onClick: async () => { if (confirm(t("confirmResetDatabase")) && confirm(t("confirmDestructiveAction"))) { await HistoryService.clear(); Toast.show(t("alertHistoryCleared"), "success"); } } }), Button({ text: t("btnExportData"), icon: UIUtils.icon(IconFileExport), onClick: () => { void BackupService.exportData(); } }), Button({ text: t("btnImportData"), icon: UIUtils.icon(IconFileImport), onClick: () => { const input = h("input", { type: "file", accept: ".json" }); input.onchange = async () => { if (!input.files?.[0]) return; const success = await BackupService.importData(input.files[0]); if (success) { Toast.show(t("alertImportSuccess"), "success"); setTimeout(() => location.reload(), TIMING.RELOAD_DELAY_NORMAL); } else { Toast.show(t("alertImportError"), "error"); } }; input.click(); } }) ), FormRow({ label: t("labelDebugMode"), children: h( "div", { className: "fc2-input-group" }, Button({ text: State.proxy.debugMode ? t("statusDebugOn") : t("statusDebugOff"), className: State.proxy.debugMode ? "primary" : "", onClick: () => { State.proxy.debugMode = !State.proxy.debugMode; Toast.show(State.proxy.debugMode ? t("alertDebugOn") : t("alertDebugOff"), "info"); render("data"); } }), Button({ text: t("btnCopyEnv"), icon: UIUtils.icon(IconSliders), onClick: () => { const info = { version: SCRIPT_INFO.VERSION, ua: navigator.userAgent, url: location.href, syncMode: State.proxy.syncMode, debugMode: State.proxy.debugMode, storage: { timestamp: ( new Date()).toISOString() } }; navigator.clipboard.writeText(JSON.stringify(info, null, 2)); Toast.show(t("alertEnvCopied"), "success"); } }) ) }) ] }); const buildSyncConfigSection = (ctrl, render) => { const children = [ FormRow({ label: t("labelSyncMode"), children: Select({ id: "syncMode", options: [ { value: "none", label: t("syncModeNone") }, { value: "webdav", label: t("syncModeWebDAV") }, { value: "supabase", label: t("syncModeSupabase") } ], controller: ctrl, binding: "syncMode", onChange: () => render("data") }) }) ]; if (State.proxy.syncMode !== "none") { children.push( FormRow({ label: t("labelSyncInterval"), children: Select({ id: "syncInterval", options: [ { value: "0", label: t("syncInterval0") }, { value: "2", label: t("syncInterval2") }, { value: "5", label: t("syncInterval5") }, { value: "10", label: t("syncInterval10") }, { value: "30", label: t("syncInterval30") }, { value: "-1", label: t("syncIntervalManual") } ], controller: ctrl, binding: "syncInterval" }) }) ); } return Card({ title: t("syncStatus"), icon: IconLink, children }); }; const buildWebDAVSection = (ctrl) => Card({ title: t("groupWebDAV"), icon: IconServer, children: [ FormRow({ label: t("labelWebDAVUrl"), children: Input({ id: "webdavUrl", type: "url", controller: ctrl, binding: "webdavUrl", placeholder: "https://..." }) }), FormRow({ label: t("labelWebDAVUser"), children: Input({ id: "webdavUser", type: "text", controller: ctrl, binding: "webdavUser" }) }), FormRow({ label: t("labelWebDAVPass"), children: PasswordInput({ id: "webdavPass", controller: ctrl, binding: "webdavPass" }) }), h( "div", { className: "fc2-card-actions" }, Button({ text: t("btnWebDAVTest"), onClick: async () => { try { await SyncService.testWebDAV(); Toast.show(t("alertWebDAVSuccess"), "success"); } catch { Toast.show(t("alertWebDAVError"), "error"); } } }), Button({ text: t("btnWebDAVSync"), className: "primary", onClick: () => SyncService.performSync(true) }), Button({ text: t("btnForceSync"), className: "danger ghost", onClick: () => { if (confirm(t("confirmForceSync"))) SyncService.forceFullSync(); } }) ) ] }); const buildSupabaseSection = (ctrl, render, displayTime) => Card({ title: t("labelSupabaseSync"), icon: IconAdjustments, children: [ FormRow({ label: t("labelSupabaseUrl"), children: Input({ id: "supabaseUrl", type: "url", controller: ctrl, binding: "supabaseUrl" }) }), FormRow({ label: t("labelSupabaseKey"), children: PasswordInput({ id: "supabaseKey", controller: ctrl, binding: "supabaseKey" }) }), FormRow({ label: t("labelAuthEmail") || "Email", children: Input({ id: "supabaseEmail", type: "email", controller: ctrl, binding: "supabaseEmail" }) }), FormRow({ label: t("labelAuthPass"), children: PasswordInput({ id: "supabasePassword", controller: ctrl, binding: "supabasePassword" }) }), h( "div", { className: "fc2-auth-section" }, h("p", { className: "dim" }, `${t("labelLastSync")}: ${displayTime}`), h( "div", { className: "fc2-card-actions" }, Button({ text: t("btnConnectAndSync"), className: "primary", onClick: async () => { Logger.debug("DataTab", "Connect and Sync clicked", { url: State.proxy.supabaseUrl, key: !!State.proxy.supabaseKey, email: State.proxy.supabaseEmail, hasPass: !!State.proxy.supabasePassword }); try { if (!State.proxy.supabaseUrl || !State.proxy.supabaseKey) { throw new Error(t("alertSbUrlRequired") || "Missing Supabase URL or Key"); } if (State.proxy.supabaseEmail && State.proxy.supabasePassword) { Logger.debug("DataTab", "Attempting login..."); await SyncService.login(State.proxy.supabaseEmail, State.proxy.supabasePassword); Toast.show(t("alertSyncAccountConnected") || "Account connected", "success"); render("data"); } await SyncService.performSync(true); } catch (e) { Logger.error("DataTab", "Action failed", e); let msg = e instanceof Error ? e.message : String(e); if (e && typeof e === "object" && "response" in e && typeof e.response === "string") { try { const errBody = JSON.parse(e.response); if (errBody.message) msg = `${errBody.message}${errBody.hint ? ` (${errBody.hint})` : ""}`; } catch { } } Toast.show(`${t("labelError") || "Error"}: ${msg}`, "error", { duration: 1e4 }); } } }), Button({ text: t("btnWebDAVSync"), onClick: () => SyncService.performSync(true) }), Button({ text: t("btnForceSync") || "Force Push", className: "danger ghost", onClick: () => SyncService.forceFullSync() }), Button({ text: t("btnPullSync") || "Force Pull", className: "danger ghost", onClick: () => SyncService.forcePullSync() }), Button({ text: t("btnLogout"), onClick: async () => { await SyncService.logout(); render("data"); } }) ) ) ] }); const renderDataTab = (_shadow, render) => { const ctrl = lifecycle$1.reset(); const lastSync = typeof GM_getValue !== "undefined" ? GM_getValue(STORAGE_KEYS.LAST_SYNC_TS, t("labelNever")) : t("labelNever"); const displayTime = lastSync !== t("labelNever") ? new Date(lastSync).toLocaleString() : t("labelNever"); const sections = [ buildStorageSection(render), buildSyncConfigSection(ctrl, render), State.proxy.syncMode === "webdav" ? buildWebDAVSection(ctrl) : null, State.proxy.syncMode === "supabase" ? buildSupabaseSection(ctrl, render, displayTime) : null ]; return h("div", { className: "fc2-data-container" }, ...sections.filter((s) => s !== null)); }; const onUnmount$2 = () => { lifecycle$1.dispose(); }; const DataTab = Object.freeze( Object.defineProperty({ __proto__: null, onUnmount: onUnmount$2, renderDataTab }, Symbol.toStringTag, { value: "Module" })); const lifecycle = new TabLifecycle(); const renderSettingsTab = () => { const ctrl = lifecycle.reset(); const portals = PortalService.getAllPortals(); const portalGrid = h( "div", { className: "portal-grid" }, ...portals.map((p) => { const enabled = State.proxy.enabledPortals || []; const isEnabled = enabled.includes(p.id); return h( "label", { className: `portal-item ${isEnabled ? "active" : ""}`, "data-portal-id": p.id }, h("input", { type: "checkbox", checked: isEnabled, onchange: (e) => { const checked = e.target.checked; let newEnabled = [...State.proxy.enabledPortals]; if (checked && !newEnabled.includes(p.id)) newEnabled.push(p.id); if (!checked) newEnabled = newEnabled.filter((id) => id !== p.id); State.proxy.enabledPortals = newEnabled; PortalService.clearCache(); } }), h("span", {}, p.name) ); }) ); ctrl.listen("enabledPortals", (val) => { const enabled = val || []; portalGrid.querySelectorAll(".portal-item").forEach((el) => { const id = el.getAttribute("data-portal-id"); if (!id) return; const active = enabled.includes(id); el.classList.toggle("active", active); const input = el.querySelector("input"); if (input) input.checked = active; }); }); return h( "div", { className: "fc2-settings-tab" }, h( "div", { className: "fc2-settings-grid" }, Card({ title: t("groupFilters"), icon: IconFilter, children: [ CheckboxRow({ id: "hideNoMagnet", label: t("optionHideNoMagnet"), controller: ctrl, binding: "hideNoMagnet" }), CheckboxRow({ id: "hideCensored", label: t("optionHideCensored"), controller: ctrl, binding: "hideCensored" }), CheckboxRow({ id: "hideViewed", label: t("optionHideViewed"), controller: ctrl, binding: "hideViewed" }) ] }), Card({ title: t("groupAppearance"), icon: IconPalette, children: [ FormRow({ label: t("labelPreviewMode"), children: Select({ id: "previewMode", options: [ { value: "static", label: t("previewModeStatic") }, { value: "hover", label: t("previewModeHover") } ], controller: ctrl, binding: "previewMode" }) }), FormRow({ label: t("labelGridColumns"), children: Select({ id: "gridColumns", options: [0, 1, 2, 3, 4, 5, 6].map((i) => ({ value: String(i), label: i === 0 ? t("labelDefault") : String(i) })), controller: ctrl, binding: "userGridColumns", onChange: (value) => { CoreEvents.emit(AppEvents.GRID_CHANGED, Number(value)); } }) }), FormRow({ label: t("labelLanguage"), children: Select({ id: "language", options: [ { value: "auto", label: t("langAuto") }, { value: "zh", label: t("langZh") }, { value: "en", label: t("langEn") } ], controller: ctrl, binding: "language" }) }) ] }), Card({ title: t("groupDataHistory"), icon: IconClockRotateLeft, className: "full-width", children: h( "div", { className: "fc2-settings-card-grid" }, CheckboxRow({ id: "enableMagnets", label: t("optionEnableMagnets"), controller: ctrl, binding: "enableMagnets" }), CheckboxRow({ id: "enableExternalLinks", label: t("optionEnableExternalLinks"), controller: ctrl, binding: "enableExternalLinks" }), CheckboxRow({ id: "enableActressName", label: t("optionEnableActressName"), controller: ctrl, binding: "enableActressName" }), CheckboxRow({ id: "replaceFc2Covers", label: t("optionReplaceFc2Covers"), controller: ctrl, binding: "replaceFc2Covers" }), CheckboxRow({ id: "enableHistory", label: t("optionEnableHistory"), controller: ctrl, binding: "enableHistory" }), CheckboxRow({ id: "loadExtraPreviews", label: t("optionLoadExtraPreviews"), controller: ctrl, binding: "loadExtraPreviews" }), CheckboxRow({ id: "enableQuickBar", label: t("optionEnableQuickBar"), controller: ctrl, binding: "enableQuickBar" }), CheckboxRow({ id: "showViewedBtn", label: t("optionShowViewedBtn"), controller: ctrl, binding: "showViewedBtn" }), CheckboxRow({ id: "showIdBadge", label: t("optionShowIdBadge"), controller: ctrl, binding: "showIdBadge" }) ) }), Card({ title: t("groupExternalPortals"), icon: IconLink, className: "full-width", children: [ h( "div", { className: "fc2-portal-actions" }, Button({ text: t("btnSelectAll"), className: "ghost micro", onClick: () => { State.proxy.enabledPortals = portals.map((p) => p.id); PortalService.clearCache(); } }), Button({ text: t("btnDeselectAll"), className: "ghost micro", onClick: () => { State.proxy.enabledPortals = []; PortalService.clearCache(); } }) ), portalGrid ] }) ) ); }; const onUnmount$1 = () => { lifecycle.dispose(); }; const SettingsTab = Object.freeze( Object.defineProperty({ __proto__: null, onUnmount: onUnmount$1, renderSettingsTab }, Symbol.toStringTag, { value: "Module" })); let container = null; let listContainer = null; const activeFilters = { [LogLevel.ERROR]: true, [LogLevel.WARN]: true, [LogLevel.INFO]: true, [LogLevel.DEBUG]: false, [LogLevel.TRACE]: false }; const createLogItem = (entry) => { const countTag = entry.count && entry.count > 1 ? ` x${entry.count}` : ""; const item = h( "div", { className: `fc2-log-item level-${(entry.levelName || "INFO").toLowerCase()}` }, h("span", { className: "fc2-log-time" }, `[${entry.timestamp}]`), h("span", { className: "fc2-log-level" }, `${entry.levelName}${countTag}`), h("span", { className: "fc2-log-module" }, `[${entry.module}]`), h("span", { className: "fc2-log-msg" }, entry.message) ); if (entry.data) { const dataView = h( "pre", { className: "fc2-log-payload", style: { display: "none" } }, JSON.stringify(entry.data, null, 2) ); const toggle = h( "button", { className: "fc2-log-payload-toggle", onclick: () => { const isHidden = dataView.style.display === "none"; dataView.style.display = isHidden ? "block" : "none"; } }, UIUtils.icon(IconDatabase), " Payload" ); item.append(toggle, dataView); } return item; }; const refresh = () => { if (!listContainer) return; listContainer.textContent = ""; const logs = [...Logger.history].reverse().filter((entry) => activeFilters[entry.level]); logs.forEach((entry) => listContainer.appendChild(createLogItem(entry))); }; const renderDebugTab = () => { container = h( "div", { className: "fc2-debug-container" }, h( "div", { className: "fc2-debug-header" }, h( "div", { className: "fc2-debug-actions" }, Button({ icon: UIUtils.icon(IconMagnifyingGlass), text: t("btnCopyAll"), onClick: () => { const text = Logger.history.map((e) => { const count = e.count && e.count > 1 ? ` x${e.count}` : ""; return `[${e.timestamp}] [${e.levelName}${count}] [${e.module}] ${e.message}`; }).join("\n"); navigator.clipboard.writeText(text); Toast.show(t("alertLogsCopied"), "success"); } }), Button({ text: t("btnClearLogs"), className: "danger", onClick: () => { Logger.clear(); refresh(); } }) ), h( "div", { className: "fc2-debug-filters" }, h("span", { className: "fc2-label-dim" }, t("labelLogFilters")), ...[LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE].map( (level) => Checkbox({ id: `filter-${level}`, label: LogLevel[level], checked: activeFilters[level], onChange: (val) => { activeFilters[level] = val; refresh(); } }) ) ) ), listContainer = h("div", { id: DOM_IDS.LOG_LIST, className: "fc2-log-list-container" }) ); refresh(); return container; }; const onUnmount = () => { container = null; listContainer = null; }; const DebugTab = Object.freeze( Object.defineProperty({ __proto__: null, onUnmount, renderDebugTab }, Symbol.toStringTag, { value: "Module" })); const safeContent = (htmlStr, className) => h("div", { className, innerHTML: htmlStr }); const renderAboutTab = () => { return h( "div", { className: "fc2-about-tab" }, h( "div", { className: "fc2-about-header" }, h("h2", {}, SCRIPT_INFO.NAME), h("div", { className: "fc2-version-badge" }, `v${SCRIPT_INFO.VERSION}`), h("p", { className: "fc2-about-desc" }, t("aboutDescription")) ), Card({ title: t("aboutHelpTitle"), icon: IconCircleInfo, children: safeContent(t("aboutHelpContent"), "fc2-about-content") }), Card({ title: t("tabDmca"), subtitle: t("labelDisclaimer"), icon: IconTriangleExclamation, className: "warning", children: safeContent(t("dmcaContent"), "fc2-about-content dmca") }), h( "div", { className: "fc2-about-footer" }, h("a", { href: SCRIPT_INFO.GREASYFORK_URL, target: "_blank", className: "fc2-link" }, t("labelGreasyFork")), h("span", { className: "dim" }, " | "), h("span", { className: "dim" }, "Designed for Efficiency & Privacy") ) ); }; const AboutTab = Object.freeze( Object.defineProperty({ __proto__: null, renderAboutTab }, Symbol.toStringTag, { value: "Module" })); })(Dexie);