// ==UserScript== // @name Background for c.ai // @namespace c.ai Background Image Customizer // @match https://character.ai/* // @run-at document-idle // @grant none // @license MIT // @version 2.2.0 // @description Custom backgrounds for Character.AI: URL / Upload (IndexedDB) / Unsplash (search+browse+select), overlay, modes, sticky scroll, import/export, optional per-chat backgrounds. // @icon https://i.imgur.com/ynjBqKW.png // @downloadURL https://update.greasyfork.icu/scripts/501065/Background%20for%20cai.user.js // @updateURL https://update.greasyfork.icu/scripts/501065/Background%20for%20cai.meta.js // ==/UserScript== (() => { "use strict"; const VERSION = "2.2.0"; // Backward compatible with v2.0/v2.1 global prefs const LS_KEY_GLOBAL = "cai_bg_customizer_v2"; // New: per-chat overrides live here const LS_KEY_CHAT_PREFIX = "cai_bg_customizer_v2_chat_"; // Key storage (encrypted-at-rest if possible) const LS_SECRET_UNSPLASH = "cai_bg_customizer_v2_unsplash_secret_v1"; const LS_SECRET_FALLBACK = "cai_bg_customizer_v2_unsplash_key_plain_fallback"; // IndexedDB for uploaded images + crypto key const DB_NAME = "caiBgCustomizer"; const DB_VERSION = 1; const STORE_IMAGES = "images"; const STORE_KEYS = "keys"; // Global-only keys (never per chat) const GLOBAL_ONLY_KEYS = new Set(["enabled", "blurBehindUI", "dimUI", "perChatEnabled"]); // Per-chat keys (overrides apply only when perChatEnabled && chatId) const CHAT_KEYS = new Set([ "imageSource", "imageUrl", "uploadImageId", "uploadImageName", "unsplashSelected", "overlayOpacity", "attachment", "position", "imageType", ]); const DEFAULTS = { // Global-only enabled: true, blurBehindUI: false, dimUI: false, perChatEnabled: false, // Image source selection (can be per chat) imageSource: "url", // "url" | "upload" | "unsplash" // URL source imageUrl: "", // Upload source uploadImageId: null, uploadImageName: "", // Unsplash unsplashSelected: null, // { id, regularUrl, fullUrl, thumbUrl, userName, userProfile, attributionText, downloadLocation } | null // Background behavior overlayOpacity: 0.35, // 0..1 attachment: "fixed", // "fixed" | "scroll" position: "center center", imageType: "Stretch", // "Stretch" | "Distort" | "ContainSingle" | "ContainRepeat" }; /** --------------------------- * Utilities * --------------------------- */ const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); function safeQS(sel) { try { return document.querySelector(sel); } catch { return null; } } function looksLikeUrl(s) { if (!s) return true; const v = String(s).trim(); if (!v) return true; if (v.startsWith("data:image/")) return true; try { const u = new URL(v, location.href); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } function uid(prefix = "id") { return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } function bufToB64(buf) { const bytes = new Uint8Array(buf); let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); } return btoa(binary); } function b64ToBuf(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } async function blobToDataUrl(blob) { return await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error || new Error("FileReader failed")); r.readAsDataURL(blob); }); } async function dataUrlToBlob(dataUrl) { const res = await fetch(dataUrl); return await res.blob(); } /** --------------------------- * Chat ID from URL * --------------------------- */ function getChatIdFromUrl() { const m = location.pathname.match(/^\/chat\/([^\/?#]+)/); return m ? m[1] : null; } let currentChatId = getChatIdFromUrl(); /** --------------------------- * Global + per-chat storage * --------------------------- */ function migrateSettings(s) { const merged = { ...DEFAULTS, ...(s || {}) }; // sanity if (!["url", "upload", "unsplash"].includes(merged.imageSource)) merged.imageSource = "url"; if (!["fixed", "scroll"].includes(merged.attachment)) merged.attachment = "fixed"; if (!["Stretch", "Distort", "ContainSingle", "ContainRepeat"].includes(merged.imageType)) merged.imageType = "Stretch"; merged.overlayOpacity = clamp(Number(merged.overlayOpacity ?? DEFAULTS.overlayOpacity), 0, 1); merged.position = (merged.position || DEFAULTS.position).trim() || DEFAULTS.position; merged.enabled = !!merged.enabled; merged.blurBehindUI = !!merged.blurBehindUI; merged.dimUI = !!merged.dimUI; merged.perChatEnabled = !!merged.perChatEnabled; return merged; } function loadGlobalSettings() { try { const raw = localStorage.getItem(LS_KEY_GLOBAL); if (!raw) return { ...DEFAULTS }; return migrateSettings(JSON.parse(raw)); } catch { return { ...DEFAULTS }; } } function saveGlobalSettings(next) { localStorage.setItem(LS_KEY_GLOBAL, JSON.stringify(next)); } function loadChatOverrides(chatId) { if (!chatId) return {}; try { const raw = localStorage.getItem(LS_KEY_CHAT_PREFIX + chatId); if (!raw) return {}; const parsed = JSON.parse(raw); // only allow known keys const clean = {}; for (const k of Object.keys(parsed || {})) { if (CHAT_KEYS.has(k)) clean[k] = parsed[k]; } return clean; } catch { return {}; } } function saveChatOverrides(chatId, overrides) { if (!chatId) return; const clean = {}; for (const k of Object.keys(overrides || {})) { if (CHAT_KEYS.has(k)) clean[k] = overrides[k]; } localStorage.setItem(LS_KEY_CHAT_PREFIX + chatId, JSON.stringify(clean)); } function clearChatOverrides(chatId) { if (!chatId) return; localStorage.removeItem(LS_KEY_CHAT_PREFIX + chatId); } function clearAllChatOverrides() { const keys = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith(LS_KEY_CHAT_PREFIX)) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); } function getAllChatOverridesMap() { const out = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k || !k.startsWith(LS_KEY_CHAT_PREFIX)) continue; const chatId = k.slice(LS_KEY_CHAT_PREFIX.length); try { out[chatId] = JSON.parse(localStorage.getItem(k) || "{}") || {}; } catch { out[chatId] = {}; } } return out; } let globalSettings = loadGlobalSettings(); function isChatScoped() { return !!(globalSettings.perChatEnabled && currentChatId); } function getEffectiveSettings() { if (!isChatScoped()) return globalSettings; const overrides = loadChatOverrides(currentChatId); return migrateSettings({ ...globalSettings, ...overrides }); } /** --------------------------- * IndexedDB (uploads + crypto key) * --------------------------- */ let dbPromise = null; function openDB() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_IMAGES)) db.createObjectStore(STORE_IMAGES, { keyPath: "id" }); if (!db.objectStoreNames.contains(STORE_KEYS)) db.createObjectStore(STORE_KEYS, { keyPath: "name" }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error("IndexedDB open failed")); }); return dbPromise; } async function idbPut(store, value) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readwrite"); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("IDB transaction failed")); tx.objectStore(store).put(value); }); } async function idbGet(store, key) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readonly"); const req = tx.objectStore(store).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error || new Error("IDB get failed")); }); } async function idbDelete(store, key) { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(store, "readwrite"); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("IDB delete failed")); tx.objectStore(store).delete(key); }); } async function storeUploadedBlob({ id, name, type, blob }) { const rec = { id: id || uid("img"), name: name || "", type: type || blob.type || "image/*", size: blob.size || 0, updatedAt: Date.now(), blob }; await idbPut(STORE_IMAGES, rec); return rec; } async function storeUploadedFile(file, forcedId = null) { return await storeUploadedBlob({ id: forcedId, name: file.name || "", type: file.type || "image/*", blob: file }); } async function getUploadedImageBlob(id) { if (!id) return null; const rec = await idbGet(STORE_IMAGES, id); return rec?.blob || null; } /** --------------------------- * Unsplash key: encrypted-at-rest if possible * --------------------------- */ async function getAesKey() { if (!crypto?.subtle) return null; const existing = await idbGet(STORE_KEYS, "aes_gcm_v1").catch(() => null); if (existing?.key) return existing.key; const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); await idbPut(STORE_KEYS, { name: "aes_gcm_v1", key }).catch(() => {}); return key; } async function encryptSecret(plaintext) { const key = await getAesKey(); if (!key) return null; const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = new TextEncoder().encode(plaintext); const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc); return { iv: bufToB64(iv.buffer), ct: bufToB64(ct) }; } async function decryptSecret(payload) { const key = await getAesKey(); if (!key) return null; const iv = new Uint8Array(b64ToBuf(payload.iv)); const ct = b64ToBuf(payload.ct); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); return new TextDecoder().decode(pt); } let unsplashAccessKeyCache = null; async function loadUnsplashKey() { if (unsplashAccessKeyCache !== null) return unsplashAccessKeyCache; try { const raw = localStorage.getItem(LS_SECRET_UNSPLASH); if (raw) { const payload = JSON.parse(raw); const key = await decryptSecret(payload); if (key) { unsplashAccessKeyCache = key; return key; } } } catch {} const fallback = localStorage.getItem(LS_SECRET_FALLBACK); unsplashAccessKeyCache = fallback ? String(fallback) : ""; return unsplashAccessKeyCache; } async function saveUnsplashKey(nextKey) { unsplashAccessKeyCache = String(nextKey || "").trim(); localStorage.removeItem(LS_SECRET_UNSPLASH); localStorage.removeItem(LS_SECRET_FALLBACK); if (!unsplashAccessKeyCache) return; const enc = await encryptSecret(unsplashAccessKeyCache); if (enc) localStorage.setItem(LS_SECRET_UNSPLASH, JSON.stringify(enc)); else localStorage.setItem(LS_SECRET_FALLBACK, unsplashAccessKeyCache); } /** --------------------------- * Unsplash API * --------------------------- */ async function unsplashFetch(path, params = {}) { const key = await loadUnsplashKey(); if (!key) throw new Error("Missing Unsplash Access Key"); const url = new URL(`https://api.unsplash.com${path}`); Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v))); const res = await fetch(url.toString(), { headers: { Authorization: `Client-ID ${key}` } }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`Unsplash error ${res.status}: ${text.slice(0, 200)}`); } return await res.json(); } async function unsplashSearch(query) { return await unsplashFetch("/search/photos", { query, per_page: 24, orientation: "landscape" }); } async function unsplashBrowse() { return await unsplashFetch("/photos", { per_page: 24, order_by: "popular" }); } async function unsplashTrackDownload(downloadLocation) { const key = await loadUnsplashKey(); if (!key || !downloadLocation) return; try { await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }); } catch {} } function normalizeUnsplashItem(item) { const userName = item?.user?.name || item?.user?.username || "Unknown"; const userProfile = item?.user?.links?.html || item?.user?.portfolio_url || ""; const attributionText = `Photo by ${userName} on Unsplash`; return { id: item.id, regularUrl: item?.urls?.regular || "", fullUrl: item?.urls?.full || "", thumbUrl: item?.urls?.thumb || item?.urls?.small || "", userName, userProfile, attributionText, downloadLocation: item?.links?.download_location || "" }; } /** --------------------------- * DOM target selection (userstyle-inspired) * --------------------------- */ function findAppContainer() { const primary = safeQS("body > div > div:has(main)"); if (primary) return primary; const byMain = safeQS("main")?.closest("div"); if (byMain) return byMain; return safeQS("#__next") || safeQS("[id*='root']") || document.body; } /** --------------------------- * Effective image URL resolution * --------------------------- */ let uploadObjectUrl = null; let uploadObjectUrlId = null; async function getEffectiveImageUrl(eff) { if (!eff.enabled) return ""; if (eff.imageSource === "url") { const url = (eff.imageUrl || "").trim(); return looksLikeUrl(url) ? url : ""; } if (eff.imageSource === "upload") { const id = eff.uploadImageId; if (!id) return ""; if (uploadObjectUrl && uploadObjectUrlId === id) return uploadObjectUrl; if (uploadObjectUrl) { try { URL.revokeObjectURL(uploadObjectUrl); } catch {} uploadObjectUrl = null; uploadObjectUrlId = null; } const blob = await getUploadedImageBlob(id); if (!blob) return ""; uploadObjectUrl = URL.createObjectURL(blob); uploadObjectUrlId = id; return uploadObjectUrl; } if (eff.imageSource === "unsplash") { const sel = eff.unsplashSelected; if (!sel) return ""; return (sel.regularUrl || sel.fullUrl || "").trim(); } return ""; } /** --------------------------- * CSS injection * --------------------------- */ const STYLE_ID = "cai-bg-customizer-style-v22"; function computeBackgroundCss(eff, imageUrl) { if (!eff.enabled || !imageUrl) { return ` :root { --cai-bg-enabled: 0; } .cai-bg-target { background: none !important; } `; } const overlay = clamp(Number(eff.overlayOpacity ?? 0.35), 0, 1); const attach = eff.attachment === "scroll" ? "scroll" : "fixed"; const pos = (eff.position || "center center").trim() || "center center"; let size = "cover"; let repeat = "no-repeat"; switch (eff.imageType) { case "Distort": size = "100vw 100vh"; repeat = "no-repeat"; break; case "ContainSingle": size = "contain"; repeat = "no-repeat"; break; case "ContainRepeat": size = "contain"; repeat = "repeat"; break; case "Stretch": default: size = "cover"; repeat = "no-repeat"; break; } const safeUrl = String(imageUrl).replace(/"/g, '\\"'); return ` :root { --cai-bg-enabled: 1; --cai-bg-overlay: ${overlay}; --cai-bg-attachment: ${attach}; --cai-bg-position: ${pos}; --cai-bg-size: ${size}; --cai-bg-repeat: ${repeat}; } .cai-bg-target { background-image: linear-gradient( rgba(0,0,0,var(--cai-bg-overlay)), rgba(0,0,0,var(--cai-bg-overlay)) ), url("${safeUrl}") !important; background-attachment: var(--cai-bg-attachment) !important; background-position: var(--cai-bg-position) !important; background-size: var(--cai-bg-size) !important; background-repeat: var(--cai-bg-repeat) !important; } `; } function computeUiOverrideCss(eff) { const disableBlur = !eff.blurBehindUI; const dimUI = !!eff.dimUI; return ` #cai-bg-ui-host { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; } #cai-bg-ui { pointer-events: auto; } ${disableBlur ? ` .max-w-2xl:has(textarea) > * { backdrop-filter: none !important; } ` : ""} .max-w-2xl:has(textarea) > * { background: transparent !important; } main > div:has(title) > div > div.pb-4 { background-color: transparent !important; } ${dimUI ? ` main [class*="bg-"], header [class*="bg-"] { background-color: rgba(0,0,0,0.25) !important; } ` : ""} `; } let applySeq = 0; let applyTimer = null; function requestApply() { clearTimeout(applyTimer); applyTimer = setTimeout(() => applyStyles(), 30); } async function applyStyles() { const seq = ++applySeq; const eff = getEffectiveSettings(); const target = findAppContainer(); target.classList.add("cai-bg-target"); const imageUrl = await getEffectiveImageUrl(eff); if (seq !== applySeq) return; let styleEl = document.getElementById(STYLE_ID); if (!styleEl) { styleEl = document.createElement("style"); styleEl.id = STYLE_ID; document.head.appendChild(styleEl); } styleEl.textContent = ` ${computeBackgroundCss(eff, imageUrl)} ${computeUiOverrideCss(eff)} `; } /** --------------------------- * Import / Export * --------------------------- */ async function buildExportObject() { const key = await loadUnsplashKey(); const chats = getAllChatOverridesMap(); // gather referenced upload IDs (global + all chat overrides) const ids = new Set(); if (globalSettings.uploadImageId) ids.add(globalSettings.uploadImageId); Object.values(chats).forEach((o) => { if (o?.uploadImageId) ids.add(o.uploadImageId); }); const uploads = []; for (const id of ids) { const blob = await getUploadedImageBlob(id); if (!blob) continue; uploads.push({ id, name: "", // name is stored in settings, not IDB (but we don't need it here) type: blob.type || "image/*", dataUrl: await blobToDataUrl(blob) }); } return { format: "cai-bg-customizer", version: VERSION, exportedAt: new Date().toISOString(), global: { ...globalSettings }, chats, secrets: { unsplashAccessKey: key || "" }, assets: { uploads } }; } async function importFromObject(obj) { if (!obj || obj.format !== "cai-bg-customizer") { throw new Error("Not a cai-bg-customizer export."); } // overwrite global globalSettings = migrateSettings(obj.global || {}); saveGlobalSettings(globalSettings); // overwrite all chat overrides clearAllChatOverrides(); const chats = obj.chats || {}; for (const [chatId, overrides] of Object.entries(chats)) { saveChatOverrides(chatId, overrides || {}); } // restore key if (obj?.secrets?.unsplashAccessKey) { await saveUnsplashKey(obj.secrets.unsplashAccessKey); } // restore uploads (keep same IDs so references stay valid) const uploads = obj?.assets?.uploads || []; for (const up of uploads) { if (!up?.id || !up?.dataUrl) continue; const blob = await dataUrlToBlob(up.dataUrl); await storeUploadedBlob({ id: up.id, name: up.name || "", type: up.type || blob.type, blob }); } } /** --------------------------- * UI (Shadow DOM) * --------------------------- */ const UI_HOST_ID = "cai-bg-ui-host"; const UI_ID = "cai-bg-ui"; const PANEL_ID = "cai-bg-panel"; const BTN_ID = "cai-bg-btn"; const TOAST_ID = "cai-bg-toast"; function ensureUI() { let host = document.getElementById(UI_HOST_ID); if (host) return host; host = document.createElement("div"); host.id = UI_HOST_ID; const ui = document.createElement("div"); ui.id = UI_ID; const shadow = ui.attachShadow({ mode: "open" }); shadow.innerHTML = `
`; host.appendChild(ui); document.body.appendChild(host); wireUI(shadow); return host; } function wireUI(shadow) { const btn = shadow.getElementById(BTN_ID); const panel = shadow.getElementById(PANEL_ID); const toast = shadow.getElementById(TOAST_ID); // Tabs const tabUrlBtn = shadow.getElementById("tabUrlBtn"); const tabUploadBtn = shadow.getElementById("tabUploadBtn"); const tabUnsplashBtn = shadow.getElementById("tabUnsplashBtn"); const tabUrl = shadow.getElementById("tab_url"); const tabUpload = shadow.getElementById("tab_upload"); const tabUnsplash = shadow.getElementById("tab_unsplash"); // Badges const sourceBadge = shadow.getElementById("sourceBadge"); const scopeBadge = shadow.getElementById("scopeBadge"); // URL tab const imgUrl = shadow.getElementById("imgUrl"); // Upload tab const pickUploadBtn = shadow.getElementById("pickUploadBtn"); const deleteUploadBtn = shadow.getElementById("deleteUploadBtn"); const uploadInput = shadow.getElementById("uploadInput"); const uploadStatus = shadow.getElementById("uploadStatus"); // Unsplash tab const unsplashKey = shadow.getElementById("unsplashKey"); const saveUnsplashKeyBtn = shadow.getElementById("saveUnsplashKeyBtn"); const unsplashQuery = shadow.getElementById("unsplashQuery"); const unsplashSearchBtn = shadow.getElementById("unsplashSearchBtn"); const unsplashBrowseBtn = shadow.getElementById("unsplashBrowseBtn"); const unsplashResults = shadow.getElementById("unsplashResults"); const unsplashStatus = shadow.getElementById("unsplashStatus"); const unsplashCredit = shadow.getElementById("unsplashCredit"); // Common controls const overlay = shadow.getElementById("overlay"); const overlayVal = shadow.getElementById("overlayVal"); const attach = shadow.getElementById("attach"); const type = shadow.getElementById("type"); const pos = shadow.getElementById("pos"); const enabled = shadow.getElementById("enabled"); const perChat = shadow.getElementById("perChat"); const perChatHint = shadow.getElementById("perChatHint"); const blurUI = shadow.getElementById("blurUI"); const dimUI = shadow.getElementById("dimUI"); // Actions const closeBtn = shadow.getElementById("closeBtn"); const resetBtn = shadow.getElementById("resetBtn"); const clearBtn = shadow.getElementById("clearBtn"); const importBtn = shadow.getElementById("importBtn"); const exportBtn = shadow.getElementById("exportBtn"); const importInput = shadow.getElementById("importInput"); function showToast(msg) { toast.textContent = msg; toast.classList.add("show"); setTimeout(() => toast.classList.remove("show"), 1400); } function pickTab(id) { const map = { url: { btn: tabUrlBtn, el: tabUrl }, upload: { btn: tabUploadBtn, el: tabUpload }, unsplash: { btn: tabUnsplashBtn, el: tabUnsplash } }; Object.entries(map).forEach(([k, v]) => { v.btn.classList.toggle("active", k === id); v.el.classList.toggle("active", k === id); }); } function syncBadges(eff) { sourceBadge.textContent = eff.imageSource === "url" ? "URL" : eff.imageSource === "upload" ? "Upload" : "Unsplash"; scopeBadge.textContent = isChatScoped() ? `This chat (${currentChatId.slice(0, 8)}…)` : "Global"; perChatHint.textContent = globalSettings.perChatEnabled ? (currentChatId ? "ON: image settings are saved per chat." : "ON: (you’re not in a chat page right now)") : "OFF: same background everywhere."; } function splitPatch(patch) { const g = {}; const c = {}; for (const [k, v] of Object.entries(patch)) { if (GLOBAL_ONLY_KEYS.has(k)) g[k] = v; else if (CHAT_KEYS.has(k)) c[k] = v; else g[k] = v; } return { globalPatch: g, chatPatch: c }; } function commit(patch, toastMsg) { const { globalPatch, chatPatch } = splitPatch(patch); // Always apply global patch if (Object.keys(globalPatch).length) { globalSettings = migrateSettings({ ...globalSettings, ...globalPatch }); saveGlobalSettings(globalSettings); } // Apply chat patch only if we're in chat scope if (Object.keys(chatPatch).length) { if (isChatScoped()) { const prev = loadChatOverrides(currentChatId); saveChatOverrides(currentChatId, { ...prev, ...chatPatch }); } else { // If not chat scoped, treat chat keys as global edits (so UI doesn't feel broken) globalSettings = migrateSettings({ ...globalSettings, ...chatPatch }); saveGlobalSettings(globalSettings); } } if (toastMsg) showToast(toastMsg); requestApply(); syncInputsFromState(); } function openPanel() { panel.classList.add("open"); syncInputsFromState(); } function closePanel() { panel.classList.remove("open"); } async function syncInputsFromState() { // refresh route context (in case SPA nav happened) currentChatId = getChatIdFromUrl(); const eff = getEffectiveSettings(); pickTab(eff.imageSource); syncBadges(eff); // URL imgUrl.value = eff.imageUrl || ""; // Upload status if (eff.uploadImageId) uploadStatus.textContent = `Selected: ${eff.uploadImageName || eff.uploadImageId}`; else uploadStatus.textContent = "No image uploaded/selected."; // Unsplash key UI: don't reveal const key = await loadUnsplashKey(); unsplashKey.value = key ? "••••••••••••••••" : ""; unsplashStatus.textContent = key ? "Ready. Search or browse." : "Enter a key to search."; renderUnsplashCredit(eff); // Common (NOTE: overlay/attachment/position/type are per-chat overridable) overlay.value = String(clamp(Number(eff.overlayOpacity ?? 0.35), 0, 1)); overlayVal.textContent = `Overlay: ${Math.round(Number(overlay.value) * 100)}%`; attach.value = eff.attachment === "scroll" ? "scroll" : "fixed"; type.value = eff.imageType || "Stretch"; pos.value = eff.position || "center center"; // Global-only enabled.checked = !!globalSettings.enabled; perChat.checked = !!globalSettings.perChatEnabled; blurUI.checked = !!globalSettings.blurBehindUI; dimUI.checked = !!globalSettings.dimUI; } function renderUnsplashCredit(eff) { const sel = eff.unsplashSelected; if (!sel) { unsplashCredit.textContent = ""; return; } const profile = sel.userProfile ? ` — ${sel.userProfile}` : ""; unsplashCredit.textContent = `${sel.attributionText}${profile}`; } // Open/close btn.addEventListener("click", () => panel.classList.contains("open") ? closePanel() : openPanel()); closeBtn.addEventListener("click", closePanel); window.addEventListener("keydown", (e) => { if (e.key === "Escape" && panel.classList.contains("open")) closePanel(); }); // Tabs set imageSource (chat-scoped if enabled) tabUrlBtn.addEventListener("click", () => commit({ imageSource: "url" }, "Source: URL")); tabUploadBtn.addEventListener("click", () => commit({ imageSource: "upload" }, "Source: Upload")); tabUnsplashBtn.addEventListener("click", () => commit({ imageSource: "unsplash" }, "Source: Unsplash")); // URL input imgUrl.addEventListener("input", () => { const v = imgUrl.value.trim(); if (!looksLikeUrl(v)) return showToast("That doesn’t look like a usable URL."); commit({ imageUrl: v, imageSource: "url" }, "Updated URL"); }); // Upload pickUploadBtn.addEventListener("click", () => uploadInput.click()); uploadInput.addEventListener("change", async () => { const file = uploadInput.files?.[0]; uploadInput.value = ""; if (!file) return; try { const meta = await storeUploadedFile(file); commit({ uploadImageId: meta.id, uploadImageName: meta.name || meta.id, imageSource: "upload" }, "Uploaded & selected"); } catch (e) { showToast(`Upload failed: ${String(e?.message || e)}`); } }); deleteUploadBtn.addEventListener("click", async () => { const eff = getEffectiveSettings(); const id = eff.uploadImageId; if (!id) return showToast("Nothing to delete."); try { await idbDelete(STORE_IMAGES, id); } catch {} if (uploadObjectUrl) { try { URL.revokeObjectURL(uploadObjectUrl); } catch {} uploadObjectUrl = null; uploadObjectUrlId = null; } // delete stored upload + unselect commit({ uploadImageId: null, uploadImageName: "" }, "Deleted upload"); }); // Unsplash key saveUnsplashKeyBtn.addEventListener("click", async () => { const raw = unsplashKey.value.trim(); if (!raw || raw.startsWith("•")) { const existing = await loadUnsplashKey(); if (existing) return showToast("Key already saved."); return showToast("Paste the actual key first."); } await saveUnsplashKey(raw); unsplashKey.value = "••••••••••••••••"; showToast("Unsplash key saved"); unsplashStatus.textContent = "Saved. Search or browse."; }); async function runUnsplash(query, mode) { unsplashStatus.textContent = "Loading…"; unsplashResults.innerHTML = ""; try { const k = await loadUnsplashKey(); if (!k) throw new Error("No access key saved."); const data = mode === "browse" ? await unsplashBrowse() : await unsplashSearch(query); const items = Array.isArray(data) ? data : (data?.results || []); if (!items.length) { unsplashStatus.textContent = "No results."; return; } unsplashStatus.textContent = `${items.length} results. Click to select.`; renderUnsplashResults(items.map(normalizeUnsplashItem)); } catch (e) { unsplashStatus.textContent = "Failed."; showToast(String(e?.message || e)); } } function renderUnsplashResults(items) { const eff = getEffectiveSettings(); const selectedId = eff.unsplashSelected?.id || null; unsplashResults.innerHTML = ""; items.forEach((it) => { const div = document.createElement("div"); div.className = "card" + (it.id === selectedId ? " selected" : ""); div.innerHTML = `
`; div.addEventListener("click", async () => { commit({ unsplashSelected: it, imageSource: "unsplash" }, "Selected Unsplash image"); if (it.downloadLocation) await unsplashTrackDownload(it.downloadLocation); // highlight [...unsplashResults.querySelectorAll(".card")].forEach((c) => c.classList.remove("selected")); div.classList.add("selected"); }); unsplashResults.appendChild(div); }); } unsplashSearchBtn.addEventListener("click", () => runUnsplash(unsplashQuery.value.trim(), "search")); unsplashBrowseBtn.addEventListener("click", () => runUnsplash("", "browse")); unsplashQuery.addEventListener("keydown", (e) => { if (e.key === "Enter") runUnsplash(unsplashQuery.value.trim(), "search"); }); // Common controls overlay.addEventListener("input", () => { overlayVal.textContent = `Overlay: ${Math.round(Number(overlay.value) * 100)}%`; commit({ overlayOpacity: clamp(Number(overlay.value), 0, 1) }); }); attach.addEventListener("change", () => commit({ attachment: attach.value }, "Attachment updated")); type.addEventListener("change", () => commit({ imageType: type.value }, "Mode updated")); pos.addEventListener("input", () => commit({ position: pos.value.trim() || "center center" })); // Global-only toggles enabled.addEventListener("change", () => commit({ enabled: enabled.checked }, enabled.checked ? "Enabled" : "Disabled")); perChat.addEventListener("change", () => { commit({ perChatEnabled: perChat.checked }, perChat.checked ? "Per-chat enabled" : "Per-chat disabled"); // When flipping, resync and reapply immediately syncInputsFromState(); requestApply(); }); blurUI.addEventListener("change", () => commit({ blurBehindUI: blurUI.checked }, "UI blur updated")); dimUI.addEventListener("change", () => commit({ dimUI: dimUI.checked }, "Panel dim updated")); // Clear image (scope-aware via commit split) clearBtn.addEventListener("click", () => { const eff = getEffectiveSettings(); if (eff.imageSource === "url") return commit({ imageUrl: "" }, "Cleared URL"); if (eff.imageSource === "unsplash") return commit({ unsplashSelected: null }, "Cleared Unsplash selection"); if (eff.imageSource === "upload") return commit({ uploadImageId: null, uploadImageName: "" }, "Unselected upload"); }); // Reset: // - If per-chat enabled and in chat => reset THIS chat overrides (back to global) // - Else reset global settings (keep Unsplash key) resetBtn.addEventListener("click", () => { if (isChatScoped()) { clearChatOverrides(currentChatId); showToast("Reset this chat to global defaults"); syncInputsFromState(); requestApply(); } else { const keepKey = globalSettings.perChatEnabled; // not a secret, just preference globalSettings = migrateSettings({ ...DEFAULTS, perChatEnabled: keepKey }); saveGlobalSettings(globalSettings); showToast("Reset global settings"); syncInputsFromState(); requestApply(); } }); // Export / Import exportBtn.addEventListener("click", async () => { try { const exportObj = await buildExportObject(); const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `cai-bg-customizer-export-v${VERSION}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1500); showToast("Exported (global + per-chat + uploads + key)"); } catch (e) { showToast(`Export failed: ${String(e?.message || e)}`); } }); importBtn.addEventListener("click", () => importInput.click()); importInput.addEventListener("change", async () => { const file = importInput.files?.[0]; importInput.value = ""; if (!file) return; try { const txt = await file.text(); await importFromObject(JSON.parse(txt)); showToast("Imported settings"); syncInputsFromState(); requestApply(); } catch (e) { showToast(`Import failed: ${String(e?.message || e)}`); } }); // initial sync syncInputsFromState(); } /** --------------------------- * Resilience: observer + SPA nav * --------------------------- */ let observer = null; function startObserver() { if (observer) observer.disconnect(); observer = new MutationObserver(() => { if (!document.getElementById(UI_HOST_ID)) ensureUI(); // Refresh chat id (SPA can change it) const nextChatId = getChatIdFromUrl(); if (nextChatId !== currentChatId) { currentChatId = nextChatId; requestApply(); } const target = findAppContainer(); if (target && !target.classList.contains("cai-bg-target")) target.classList.add("cai-bg-target"); if (!document.getElementById(STYLE_ID)) requestApply(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); } function hookHistory() { const _pushState = history.pushState; const _replaceState = history.replaceState; function onNav() { setTimeout(() => { globalSettings = loadGlobalSettings(); // pick up any changes const nextChatId = getChatIdFromUrl(); currentChatId = nextChatId; ensureUI(); requestApply(); }, 60); } history.pushState = function (...args) { const out = _pushState.apply(this, args); onNav(); return out; }; history.replaceState = function (...args) { const out = _replaceState.apply(this, args); onNav(); return out; }; window.addEventListener("popstate", onNav); } /** --------------------------- * Boot * --------------------------- */ function boot() { ensureUI(); requestApply(); startObserver(); hookHistory(); } boot(); })();