NovelAI Prompt Profiles (Ultimate v2.0)

Ctrl+1-9, auto-numbered, last profile remembered, light theme, backup/restore JSON.

// ==UserScript==
// @name         NovelAI Prompt Profiles (Ultimate v2.0)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Ctrl+1-9, auto-numbered, last profile remembered, light theme, backup/restore JSON.
// @match        https://novelai.net/image*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = "nai_prompt_profiles_v2";
    const LAST_PROFILE_KEY = "nai_last_profile";

    // Struktur: array agar bisa urut
    let profiles = [];
    let lastProfileName = localStorage.getItem(LAST_PROFILE_KEY);

    try {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
            const parsed = JSON.parse(saved);
            if (Array.isArray(parsed)) {
                profiles = parsed;
            }
        }
    } catch (e) {
        console.error("Gagal muat profiles:", e);
    }

    function saveProfilesToStorage() {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(profiles));
        } catch (e) {
            console.error("Gagal simpan profiles:", e);
        }
    }

    function updateSelectOptions(select, selectedName = null) {
        select.innerHTML = "";
        profiles.forEach((p, i) => {
            const opt = document.createElement("option");
            opt.value = p.name;
            opt.textContent = `${i + 1}. ${p.name}`;
            select.appendChild(opt);
        });
        // Set pilihan ke profil terakhir jika ada
        if (selectedName && profiles.some(p => p.name === selectedName)) {
            select.value = selectedName;
        } else if (profiles.length > 0) {
            select.selectedIndex = 0;
        }
    }

    // Simpan nama profil terakhir
    function setLastProfile(name) {
        lastProfileName = name;
        localStorage.setItem(LAST_PROFILE_KEY, name);
    }

    // Cari editor ProseMirror
    function findSiteEditor() {
        return document.querySelector('.image-gen-prompt-main .ProseMirror') ||
               document.querySelector('.prompt-input-box-prompt .ProseMirror');
    }

    // Cari ProseMirror view
    function findPMView(node, maxDepth = 6) {
        let el = node;
        let depth = 0;
        while (el && depth < maxDepth) {
            try {
                const maybeKeys = Object.keys(el);
                for (const k of maybeKeys) {
                    try {
                        const v = el[k];
                        if (v && typeof v === 'object' && v.state && typeof v.dispatch === 'function') {
                            return v;
                        }
                    } catch (e) {}
                }
                if (el.pmView) return el.pmView;
                if (el.__pmView) return el.__pmView;
                if (el._pmView) return el._pmView;
                if (el.__view) return el.__view;
                if (el._view) return el._view;
            } catch (e) {}
            el = el.parentNode;
            depth++;
        }
        return null;
    }

    // Terapkan teks ke editor
    async function applyTextToEditor(text, statusEl) {
        if (!text) {
            statusEl.textContent = "⚠️ Teks kosong.";
            return false;
        }

        statusEl.textContent = "🔍 Mencari editor...";
        const editor = findSiteEditor();
        if (!editor) {
            statusEl.textContent = "❌ Editor tidak ditemukan.";
            return false;
        }

        const view = findPMView(editor);
        if (view) {
            try {
                const tr = view.state.tr;
                tr.delete(0, view.state.doc.content.size);
                tr.insertText(text);
                view.dispatch(tr);
                statusEl.textContent = "✅ Berhasil (ProseMirror)";
                return true;
            } catch (e) {
                console.error("PM view dispatch error:", e);
                statusEl.textContent = "⚠️ Gagal (view), coba metode lain...";
            }
        }

        // Fallback: execCommand insertText
        try {
            editor.focus();
            const range = document.createRange();
            range.selectNodeContents(editor);
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);

            const okIns = document.execCommand('insertText', false, text);
            editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
            editor.dispatchEvent(new Event('change', { bubbles: true }));

            if (okIns) {
                statusEl.textContent = "✅ Berhasil (execCommand)";
                return true;
            }
        } catch (e) {
            console.error("execCommand error:", e);
            statusEl.textContent = "⚠️ Gagal (execCommand), coba clipboard...";
        }

        // Clipboard fallback
        try {
            await navigator.clipboard.writeText(text);
            statusEl.textContent = "📋 Tersalin! Paste manual (Ctrl+V)";
            return false;
        } catch (e) {
            console.error("Clipboard error:", e);
            statusEl.textContent = "❌ Gagal salin ke clipboard.";
            return false;
        }
    }

    // Buat panel
    function createPanelOnce() {
        if (document.getElementById('nai-profiles-panel')) return;

        const container = document.querySelector('.image-gen-prompt-main') ||
                         document.querySelector('.prompt-input-box-prompt');
        if (!container) {
            setTimeout(createPanelOnce, 500);
            return;
        }

        // Ikon draggable
        const toggle = document.createElement('div');
        toggle.id = "nai-profiles-toggle";
        toggle.style.position = "fixed";
        toggle.style.top = "10px";
        toggle.style.left = "10px";
        toggle.style.zIndex = "10000";
        toggle.style.cursor = "move";
        toggle.style.fontSize = "18px";
        toggle.style.padding = "6px";
        toggle.style.backgroundColor = "#f3f4f6";
        toggle.style.color = "#1f2937";
        toggle.style.border = "1px solid #d1d5db";
        toggle.style.borderRadius = "6px";
        toggle.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
        toggle.style.userSelect = "none";
        toggle.title = "Drag to move | Click to open";
        toggle.textContent = "📝";

        let isDragging = false;
        let offsetX, offsetY;

        toggle.addEventListener("mousedown", (e) => {
            isDragging = true;
            offsetX = e.clientX - toggle.getBoundingClientRect().left;
            offsetY = e.clientY - toggle.getBoundingClientRect().top;
            toggle.style.opacity = "0.8";
        });

        document.addEventListener("mousemove", (e) => {
            if (!isDragging) return;
            toggle.style.left = `${e.clientX - offsetX}px`;
            toggle.style.top = `${e.clientY - offsetY}px`;
        });

        document.addEventListener("mouseup", () => {
            isDragging = false;
            toggle.style.opacity = "1";
        });

        document.body.appendChild(toggle);

        // Panel utama (tema ringan)
        const panel = document.createElement('div');
        panel.id = "nai-profiles-panel";
        panel.style.position = "fixed";
        panel.style.zIndex = "9999";
        panel.style.width = "320px";
        panel.style.maxHeight = "420px";
        panel.style.overflowY = "auto";
        panel.style.background = "#ffffff";
        panel.style.color = "#111827";
        panel.style.border = "1px solid #e5e7eb";
        panel.style.borderRadius = "10px";
        panel.style.padding = "12px";
        panel.style.boxShadow = "0 10px 25px rgba(0,0,0,0.1)";
        panel.style.fontSize = "13px";
        panel.style.display = "none";

        // Header
        const hdr = document.createElement('div');
        hdr.style.display = "flex";
        hdr.style.justifyContent = "space-between";
        hdr.style.alignItems = "center";
        hdr.style.marginBottom = "10px";
        hdr.style.fontWeight = "600";
        hdr.style.color = "#374151";
        hdr.style.fontSize = "15px";
        hdr.textContent = "Prompt Profiles";

        const btnClose = document.createElement('button');
        btnClose.textContent = "✕";
        btnClose.style.background = "transparent";
        btnClose.style.border = "none";
        btnClose.style.color = "#9ca3af";
        btnClose.style.cursor = "pointer";
        btnClose.style.fontSize = "14px";
        btnClose.onclick = () => panel.style.display = "none";

        hdr.appendChild(btnClose);
        panel.appendChild(hdr);

        // Select
        const select = document.createElement('select');
        select.style.width = "100%";
        select.style.height = "60px";
        select.style.marginBottom = "8px";
        select.style.background = "#f9fafb";
        select.style.color = "#111827";
        select.style.border = "1px solid #d1d5db";
        select.style.borderRadius = "6px";
        select.style.fontFamily = "inherit";
        updateSelectOptions(select, lastProfileName);
        panel.appendChild(select);

        // Textarea
        const ta = document.createElement('textarea');
        ta.style.width = "100%";
        ta.style.height = "100px";
        ta.style.marginBottom = "8px";
        ta.style.padding = "10px";
        ta.style.background = "#f9fafb";
        ta.style.color = "#111827";
        ta.style.border = "1px solid #d1d5db";
        ta.style.borderRadius = "6px";
        ta.style.resize = "vertical";
        ta.style.fontFamily = "inherit";
        ta.placeholder = "Isi prompt profile...";
        panel.appendChild(ta);

        // Status
        const status = document.createElement('div');
        status.style.marginTop = "6px";
        status.style.fontSize = "12px";
        status.style.color = "#4b5563";
        status.style.textAlign = "center";
        status.style.padding = "6px";
        status.style.background = "#f3f4f6";
        status.style.borderRadius = "6px";
        status.textContent = "Siap";

        // Tombol
        const row = document.createElement('div');
        row.style.display = "grid";
        row.style.gridTemplateColumns = "1fr 1fr";
        row.style.gap = "8px";

        function mkBtn(label, cb, bg = "#3b82f6", color = "#fff") {
            const b = document.createElement('button');
            b.textContent = label;
            b.style.padding = "7px";
            b.style.border = "none";
            b.style.borderRadius = "6px";
            b.style.background = bg;
            b.style.color = color;
            b.style.fontSize = "12px";
            b.style.fontWeight = "500";
            b.style.cursor = "pointer";
            b.style.transition = "background 0.2s";
            b.onmouseover = () => b.style.background = darken(bg, 10);
            b.onmouseout = () => b.style.background = bg;
            b.onclick = cb;
            return b;
        }

        // Helper: warna gelap sedikit
        function darken(hex, percent) {
            const num = parseInt(hex.replace("#",""), 16);
            const amt = Math.round(2.55 * percent);
            const R = (num >> 16) - amt;
            const G = (num >> 8 & 0x00FF) - amt;
            const B = (num & 0x0000FF) - amt;
            return "#" + (0x1000000 + (R<0?0:R)*65536 + (G<0?0:G)*256 + (B<0?0:B)).toString(16).slice(1);
        }

        const btnNew = mkBtn("New", () => {
            const input = prompt("Nama profil baru:");
            if (!input || !input.trim()) {
                status.textContent = "❌ Nama tidak valid.";
                return;
            }
            const name = input.trim();
            if (profiles.some(p => p.name === name)) {
                status.textContent = `❌ Sudah ada "${name}".`;
                return;
            }
            profiles.push({ name, content: ta.value || "" });
            saveProfilesToStorage();
            updateSelectOptions(select, name);
            select.value = name;
            select.dispatchEvent(new Event('change'));
            setLastProfile(name);
            status.textContent = `✅ "${name}" dibuat.`;
        }, "#10b981");

        const btnSave = mkBtn("Save", () => {
            const name = select.value;
            if (!name) {
                status.textContent = "❌ Pilih profil dulu.";
                return;
            }
            const idx = profiles.findIndex(p => p.name === name);
            if (idx !== -1) {
                profiles[idx].content = ta.value;
            }
            saveProfilesToStorage();
            ta.value = profiles[idx].content;
            syncTextarea();
            status.textContent = `💾 "${name}" tersimpan.`;
        });

        const btnRename = mkBtn("Rename", () => {
            const oldName = select.value;
            if (!oldName) {
                status.textContent = "❌ Pilih profil dulu.";
                return;
            }
            const newName = prompt("Nama baru:", oldName);
            if (!newName || !newName.trim()) {
                status.textContent = "❌ Nama baru tidak valid.";
                return;
            }
            const trimmed = newName.trim();
            if (profiles.some(p => p.name === trimmed)) {
                status.textContent = `❌ Sudah ada "${trimmed}".`;
                return;
            }
            const idx = profiles.findIndex(p => p.name === oldName);
            profiles[idx].name = trimmed;
            if (lastProfileName === oldName) {
                setLastProfile(trimmed);
            }
            saveProfilesToStorage();
            updateSelectOptions(select, trimmed);
            select.value = trimmed;
            syncTextarea();
            status.textContent = `✏️ "${oldName}" → "${trimmed}"`;
        });

        const btnDelete = mkBtn("Delete", () => {
            const name = select.value;
            if (!name) {
                status.textContent = "❌ Pilih profil dulu.";
                return;
            }
            if (confirm(`Hapus "${name}"?`)) {
                profiles = profiles.filter(p => p.name !== name);
                saveProfilesToStorage();
                updateSelectOptions(select);
                ta.value = "";
                syncTextarea();
                if (lastProfileName === name) {
                    localStorage.removeItem(LAST_PROFILE_KEY);
                    lastProfileName = null;
                }
                status.textContent = `🗑️ "${name}" dihapus.`;
            }
        }, "#ef4444");

        const btnApply = mkBtn("Apply →", async () => {
            const name = select.value;
            if (!name) {
                status.textContent = "❌ Pilih profil dulu.";
                return;
            }
            const idx = profiles.findIndex(p => p.name === name);
            if (idx === -1) return;
            const text = profiles[idx].content;
            ta.value = text;
            syncTextarea();
            setLastProfile(name);
            status.textContent = "🔧 Menerapkan...";
            const ok = await applyTextToEditor(text, status);
            if (!ok) status.textContent = "📋 Gagal otomatis — teks disalin!";
        });

        // Backup / Restore
        const btnBackup = mkBtn("Backup", () => {
            const blob = new Blob([JSON.stringify(profiles, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `nai-profiles-backup-${new Date().toISOString().split('T')[0]}.json`;
            a.click();
            URL.revokeObjectURL(url);
            status.textContent = "📦 Backup berhasil diunduh.";
        }, "#8b5cf6");

        const btnRestore = mkBtn("Restore", () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.json';
            input.onchange = (e) => {
                const file = e.target.files[0];
                if (!file) return;
                const reader = new FileReader();
                reader.onload = (ev) => {
                    try {
                        const data = JSON.parse(ev.target.result);
                        if (Array.isArray(data)) {
                            profiles = data;
                            saveProfilesToStorage();
                            updateSelectOptions(select);
                            ta.value = "";
                            syncTextarea();
                            status.textContent = `🔄 ${data.length} profil dipulihkan.`;
                        } else {
                            status.textContent = "❌ Format file tidak valid.";
                        }
                    } catch (err) {
                        status.textContent = "❌ Gagal baca file.";
                        console.error(err);
                    }
                };
                reader.readAsText(file);
            };
            input.click();
        }, "#06b6d4");

// Masukkan semua tombol ke dalam grid 2x3
row.appendChild(btnNew);
row.appendChild(btnSave);
row.appendChild(btnRename);
row.appendChild(btnDelete);
row.appendChild(btnApply);
row.appendChild(btnBackup);
panel.appendChild(row);

// Buat baris baru untuk Restore — full width, ukuran sama
const restoreContainer = document.createElement('div');
restoreContainer.style.marginTop = "8px";
restoreContainer.style.display = "grid";
restoreContainer.style.gridTemplateColumns = "1fr"; /* Lebar penuh */
restoreContainer.style.gap = "8px";
restoreContainer.appendChild(btnRestore);
panel.appendChild(restoreContainer);

// Status di akhir
panel.appendChild(status);

        // Sinkronisasi textarea
        function syncTextarea() {
            ta.dispatchEvent(new Event('input', { bubbles: true }));
            ta.dispatchEvent(new Event('change', { bubbles: true }));
        }

        // Muat saat pilih
        select.addEventListener('change', () => {
            const name = select.value;
            const profile = profiles.find(p => p.name === name);
            ta.value = profile ? profile.content : "";
            syncTextarea();
            if (name) setLastProfile(name);
            status.textContent = name ? `📄 Dimuat: ${name}` : "Tidak ada profil dipilih.";
        });

        // Auto-load profil terakhir saat buka panel
        toggle.addEventListener('click', (e) => {
            e.stopPropagation();
            const rect = toggle.getBoundingClientRect();
            panel.style.top = `${rect.bottom + window.scrollY + 4}px`;
            panel.style.left = `${rect.left + window.scrollX}px`;

            if (panel.style.display === "none" && lastProfileName) {
                const profile = profiles.find(p => p.name === lastProfileName);
                if (profile) {
                    select.value = lastProfileName;
                    ta.value = profile.content;
                    syncTextarea();
                }
            }

            panel.style.display = panel.style.display === "none" ? "block" : "none";
        });

        // Shortcut Ctrl+1 s/d Ctrl+9
        document.addEventListener('keydown', (e) => {
            if (!e.ctrlKey || e.altKey || e.shiftKey) return;
            const key = e.key;
            if (!/^[1-9]$/.test(key)) return;
            e.preventDefault();

            const idx = parseInt(key) - 1;
            if (idx >= profiles.length || idx < 0) {
                status.textContent = `⚠️ Profil ${key} belum ada.`;
                return;
            }

            const { name, content } = profiles[idx];
            ta.value = content;
            syncTextarea();
            status.textContent = `⚡ Ctrl+${key}: Apply "${name}"`;
            applyTextToEditor(content, status).catch(console.error);
            setLastProfile(name);
        });

        document.body.appendChild(panel);
    }

    // Tunda inisialisasi
    setTimeout(() => {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', createPanelOnce);
        } else {
            createPanelOnce();
        }
    }, 1000);

    const observer = new MutationObserver(() => {
        if (!document.getElementById('nai-profiles-panel')) {
            createPanelOnce();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();