ChatGPT Custom Instructions Manager

Advanced save and manage custom instructions for ChatGPT

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ChatGPT Custom Instructions Manager
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Advanced save and manage custom instructions for ChatGPT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @author       Dramorian
// @match        https://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_setClipboard
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    const CONFIG = Object.freeze({
        storagePrefix: 'ci_',
        charLimit: 1500,
        charWarning: 1200,
        categories: ['All', 'Coding', 'Writing', 'Analysis', 'Creative', 'Learning', 'Other'],
        selectors: {
            personalizationHash: 'settings/Personalization',
            pageSection: 'section.flex.flex-col.gap-2.pt-7',
            chatgptTextarea: 'textarea[name="traits_model_message"]',
            toggleBtn: '.ci-toggle-btn',
            panel: '.ci-manager',
            list: '.ci-list',
            contentInput: '.ci-content-input',
            charCounter: '.ci-char-counter',
            nameInput: '.ci-name-input',
            categorySelect: '.ci-category-select',
            saveBtn: '.ci-save-btn',
            loadCurrentBtn: '.ci-load-current-btn',
            closeBtn: '.ci-close',
            exportBtn: '.ci-export-btn',
            importBtn: '.ci-import-btn',
            fileInput: '.ci-file-input',
            searchInput: '.ci-search-input',
            searchClear: '.ci-search-clear',
            sortSelect: '.ci-sort-select',
            filterBtn: '.ci-filter-btn',
        },
    });

    const styles = `
    .ci-manager {
      background: #fff;
      border: 1px solid #d1d5db;
      border-radius: 12px;
      box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
      padding: 16px;
      max-width: 450px;
      font-family: system-ui, -apple-system, sans-serif;
      margin-top: 16px;
    }
    .ci-manager-dark {
      background: #2d2d2d;
      border-color: #4a4a4a;
      color: #e5e5e5;
    }
    .ci-toggle-btn {
      background: #10a37f;
      color: white;
      border: none;
      border-radius: 8px;
      padding: 10px 16px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 600;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      margin-top: 12px;
      display: inline-flex;
      align-items: center;
      gap: 8px;
    }
    .ci-toggle-btn:hover { background: #0d8f6f; }

    .ci-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 16px;
    }
    .ci-title { font-size: 16px; font-weight: 600; margin: 0; }
    .ci-close {
      background: none;
      border: none;
      font-size: 20px;
      cursor: pointer;
      color: #6b7280;
      padding: 0;
      width: 24px;
      height: 24px;
    }

    .ci-message {
      border-radius: 8px;
      padding: 10px 12px;
      margin: 0 0 12px;
      font-size: 13px;
      border: 1px solid transparent;
    }
    .ci-message-success { color: #065f46; background: #ecfdf5; border-color: #a7f3d0; }
    .ci-message-error { color: #7f1d1d; background: #fef2f2; border-color: #fecaca; }

    .ci-toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
    .ci-toolbar-btn {
      padding: 6px 12px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      background: white;
      cursor: pointer;
      font-size: 12px;
      display: inline-flex;
      align-items: center;
      gap: 4px;
    }
    .ci-manager-dark .ci-toolbar-btn {
      background: #3a3a3a;
      border-color: #4a4a4a;
      color: #e5e5e5;
    }
    .ci-toolbar-btn:hover { background: #f3f4f6; }
    .ci-manager-dark .ci-toolbar-btn:hover { background: #4a4a4a; }

    .ci-input {
      width: 100%;
      padding: 8px 12px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      font-size: 14px;
      margin-bottom: 12px;
      box-sizing: border-box;
    }
    .ci-manager-dark .ci-input {
      background: #3a3a3a;
      border-color: #4a4a4a;
      color: #e5e5e5;
    }
    .ci-textarea { min-height: 100px; resize: vertical; font-family: inherit; }

    .ci-search-box { position: relative; margin-bottom: 12px; }
    .ci-search-input {
      width: 100%;
      padding: 8px 32px 8px 12px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      font-size: 14px;
      box-sizing: border-box;
    }
    .ci-manager-dark .ci-search-input {
      background: #3a3a3a;
      border-color: #4a4a4a;
      color: #e5e5e5;
    }
    .ci-search-clear {
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      background: none;
      border: none;
      cursor: pointer;
      color: #9ca3af;
      font-size: 16px;
    }

    .ci-filters { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
    .ci-filter-btn {
      padding: 4px 10px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      background: white;
      cursor: pointer;
      font-size: 12px;
    }
    .ci-filter-btn.active { background: #10a37f; color: white; border-color: #10a37f; }
    .ci-manager-dark .ci-filter-btn { background: #3a3a3a; border-color: #4a4a4a; color: #e5e5e5; }
    .ci-manager-dark .ci-filter-btn.active { background: #10a37f; border-color: #10a37f; }

    .ci-btn {
      width: 100%;
      padding: 8px 16px;
      border: none;
      border-radius: 6px;
      font-size: 14px;
      font-weight: 600;
      cursor: pointer;
      margin-bottom: 8px;
    }
    .ci-btn-primary { background: #10a37f; color: white; }
    .ci-btn-primary:hover { background: #0d8f6f; }
    .ci-btn-secondary { background: #6b7280; color: white; }
    .ci-btn-secondary:hover { background: #4b5563; }

    .ci-sort-select {
      padding: 6px 12px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      font-size: 12px;
      background: white;
    }
    .ci-manager-dark .ci-sort-select {
      background: #3a3a3a;
      border-color: #4a4a4a;
      color: #e5e5e5;
    }

    .ci-divider { height: 1px; background: #e5e7eb; margin: 16px 0; }
    .ci-manager-dark .ci-divider { background: #4a4a4a; }

    .ci-list { margin-top: 16px; max-height: 350px; overflow-y: auto; }

    .ci-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 10px;
      border: 1px solid #e5e7eb;
      border-radius: 6px;
      margin-bottom: 8px;
      background: #f9fafb;
      transition: all 0.2s;
    }
    .ci-item.pinned { border-color: #fbbf24; background: #fffbeb; }
    .ci-manager-dark .ci-item { background: #3a3a3a; border-color: #4a4a4a; }
    .ci-manager-dark .ci-item.pinned { border-color: #fbbf24; background: #3d3520; }

    .ci-item-left { flex: 1; min-width: 0; }
    .ci-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
    .ci-item-name { font-weight: 500; font-size: 14px; cursor: pointer; }
    .ci-item-name:hover { color: #10a37f; }

    .ci-item-tag {
      font-size: 11px;
      padding: 2px 6px;
      border-radius: 4px;
      background: #e5e7eb;
      color: #374151;
      white-space: nowrap;
    }
    .ci-manager-dark .ci-item-tag { background: #4a4a4a; color: #d1d5db; }

    .ci-item-meta { font-size: 11px; color: #9ca3af; }

    .ci-item-actions { display: flex; gap: 4px; margin-left: 8px; }
    .ci-icon-btn {
      background: none;
      border: none;
      cursor: pointer;
      font-size: 16px;
      padding: 4px;
      color: #6b7280;
      min-width: 24px;
    }
    .ci-icon-btn:hover { color: #374151; }
    .ci-manager-dark .ci-icon-btn:hover { color: #e5e5e5; }
    .ci-icon-btn.pinned { color: #fbbf24; }

    .ci-hidden { display: none; }

    .ci-char-counter {
      font-size: 11px;
      color: #6b7280;
      text-align: right;
      margin-top: -8px;
      margin-bottom: 12px;
    }
    .ci-char-counter.warning { color: #f59e0b; }
    .ci-char-counter.error { color: #ef4444; }

    .ci-file-input { display: none; }
  `;

    /** @returns {HTMLElement|null} */
    const qs = (sel, root = document) => root.querySelector(sel);
    /** @returns {HTMLElement[]} */
    const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));

    const debounce = (fn, waitMs) => {
        let t = 0;
        return (...args) => {
            window.clearTimeout(t);
            t = window.setTimeout(() => fn(...args), waitMs);
        };
    };

    const raf = (fn) => window.requestAnimationFrame(fn);

    const isPersonalizationPage = () => window.location.hash.includes(CONFIG.selectors.personalizationHash);

    const isDarkMode = () =>
    document.documentElement.classList.contains('dark') || document.body.classList.contains('dark');

    const injectStylesOnce = (() => {
        let done = false;
        return () => {
            if (done) return;
            const styleEl = document.createElement('style');
            styleEl.textContent = styles;
            document.head.appendChild(styleEl);
            done = true;
        };
    })();

    const formatDate = (ts) => {
        try {
            return new Intl.DateTimeFormat(undefined, {
                year: 'numeric',
                month: '2-digit',
                day: '2-digit',
                hour: '2-digit',
                minute: '2-digit',
            }).format(new Date(ts));
        } catch {
            return new Date(ts).toLocaleString();
        }
    };

    // ---- Storage ----

    /**
   * @typedef {{
   *  content: string,
   *  category: string,
   *  isPinned: boolean,
   *  createdAt: number,
   *  lastUsed: number
   * }} Instruction
   */

    const Storage = {
        /** @returns {Record<string, Instruction>} */
        loadAll() {
            /** @type {Record<string, Instruction>} */
            const out = {};
            const keys = GM_listValues();
            for (const key of keys) {
                if (!key.startsWith(CONFIG.storagePrefix)) continue;
                const name = key.slice(CONFIG.storagePrefix.length);
                try {
                    const raw = GM_getValue(key);
                    const parsed = JSON.parse(raw);
                    if (parsed && typeof parsed.content === 'string') out[name] = parsed;
                } catch (e) {
                    // Ignore corrupt entries
                    // eslint-disable-next-line no-console
                    console.error('Failed to parse instruction:', key, e);
                }
            }
            return out;
        },

        /** @param {string} name @param {Instruction} instruction */
        set(name, instruction) {
            GM_setValue(CONFIG.storagePrefix + name, JSON.stringify(instruction));
        },

        /** @param {string} name */
        remove(name) {
            GM_deleteValue(CONFIG.storagePrefix + name);
        },
    };

    // ---- UI ----

    const UI = (() => {
        /**
     * Central state holder.
     * Keeping mutable state in one object avoids "shadow" variables and event-handler mismatches.
     */
        const state = {
            mounted: false,
            dark: false,
            visible: false,
            loaded: false,
            abort: /** @type {AbortController|null} */ (null),

            toggleBtn: /** @type {HTMLButtonElement|null} */ (null),
            panel: /** @type {HTMLDivElement|null} */ (null),

            instructions: /** @type {Record<string, Instruction>} */ ({}),

            searchQuery: '',
            selectedCategory: 'All',
            sortBy: 'lastUsed',

            editMode: /** @type {{ active: boolean, originalName: string|null }} */ ({ active: false, originalName: null }),

            messageTimeoutId: 0,
        };

        const dom = {
            /** @returns {HTMLTextAreaElement|null} */
            chatgptTextarea() {
                return /** @type {HTMLTextAreaElement|null} */ (qs(CONFIG.selectors.chatgptTextarea));
            },

            /** @returns {HTMLTextAreaElement|null} */
            contentInput() {
                return /** @type {HTMLTextAreaElement|null} */ (qs(CONFIG.selectors.contentInput, state.panel ?? undefined));
            },

            /** @returns {HTMLDivElement|null} */
            charCounter() {
                return /** @type {HTMLDivElement|null} */ (qs(CONFIG.selectors.charCounter, state.panel ?? undefined));
            },

            /** @returns {HTMLInputElement|null} */
            nameInput() {
                return /** @type {HTMLInputElement|null} */ (qs(CONFIG.selectors.nameInput, state.panel ?? undefined));
            },

            /** @returns {HTMLSelectElement|null} */
            categorySelect() {
                return /** @type {HTMLSelectElement|null} */ (qs(CONFIG.selectors.categorySelect, state.panel ?? undefined));
            },

            /** @returns {HTMLButtonElement|null} */
            saveBtn() {
                return /** @type {HTMLButtonElement|null} */ (qs(CONFIG.selectors.saveBtn, state.panel ?? undefined));
            },

            /** @returns {HTMLDivElement|null} */
            list() {
                return /** @type {HTMLDivElement|null} */ (qs(CONFIG.selectors.list, state.panel ?? undefined));
            },

            /** @returns {HTMLInputElement|null} */
            fileInput() {
                return /** @type {HTMLInputElement|null} */ (qs(CONFIG.selectors.fileInput, state.panel ?? undefined));
            },

            /** @returns {HTMLInputElement|null} */
            searchInput() {
                return /** @type {HTMLInputElement|null} */ (qs(CONFIG.selectors.searchInput, state.panel ?? undefined));
            },

            /** @returns {HTMLButtonElement|null} */
            searchClear() {
                return /** @type {HTMLButtonElement|null} */ (qs(CONFIG.selectors.searchClear, state.panel ?? undefined));
            },

            /** @returns {HTMLSelectElement|null} */
            sortSelect() {
                return /** @type {HTMLSelectElement|null} */ (qs(CONFIG.selectors.sortSelect, state.panel ?? undefined));
            },
        };

        const showMessage = (text, type = 'success') => {
            if (!state.panel) return;
            const existing = qs('.ci-message', state.panel);
            if (existing) existing.remove();
            window.clearTimeout(state.messageTimeoutId);

            const msg = document.createElement('div');
            msg.className = `ci-message ci-message-${type === 'error' ? 'error' : 'success'}`;
            msg.textContent = text;
            const header = qs('.ci-header', state.panel);
            header?.insertAdjacentElement('afterend', msg);

            state.messageTimeoutId = window.setTimeout(() => msg.remove(), 3000);
        };

        const updateCharCounter = () => {
            const input = dom.contentInput();
            const counter = dom.charCounter();
            if (!input || !counter) return;

            const len = (input.value || '').length;
            counter.textContent = `${len} / ${CONFIG.charLimit} characters`;
            counter.className = 'ci-char-counter';

            if (len >= CONFIG.charLimit) counter.classList.add('error');
            else if (len >= CONFIG.charWarning) counter.classList.add('warning');
        };

        const setContentInputValue = (value) => {
            const input = dom.contentInput();
            if (!input) return;
            input.value = value;
            // Programmatic set does not trigger input events.
            raf(updateCharCounter);
        };

        const loadIfNeeded = () => {
            if (state.loaded) return;
            state.instructions = Storage.loadAll();
            state.loaded = true;
        };

        const getSortedFilteredNames = () => {
            const q = state.searchQuery.trim().toLowerCase();
            const names = Object.keys(state.instructions).filter((name) => {
                const instr = state.instructions[name];
                const matchesSearch =
                      !q ||
                      name.toLowerCase().includes(q) ||
                      (instr.content || '').toLowerCase().includes(q);
                const matchesCategory = state.selectedCategory === 'All' || instr.category === state.selectedCategory;
                return matchesSearch && matchesCategory;
            });

            names.sort((a, b) => {
                const A = state.instructions[a];
                const B = state.instructions[b];

                // Pinned first
                if (A.isPinned !== B.isPinned) return A.isPinned ? -1 : 1;

                switch (state.sortBy) {
                    case 'name':
                        return a.localeCompare(b);
                    case 'dateCreated':
                        return (B.createdAt || 0) - (A.createdAt || 0);
                    case 'lastUsed':
                    default:
                        return (B.lastUsed || 0) - (A.lastUsed || 0);
                }
            });

            return names;
        };

        const renderList = () => {
            const list = dom.list();
            if (!list) return;

            const names = getSortedFilteredNames();
            if (names.length === 0) {
                list.innerHTML = '<p style="text-align:center;color:#9ca3af;font-size:14px;padding:20px;">No instructions found</p>';
                return;
            }

            const html = names
            .map((name) => {
                const instr = state.instructions[name];
                const pinned = instr.isPinned ? 'pinned' : '';
                const pinCls = instr.isPinned ? 'pinned' : '';
                const created = formatDate(instr.createdAt);
                const used = formatDate(instr.lastUsed);

                // Use data-name for event delegation.
                return `
            <div class="ci-item ${pinned}" data-name="${escapeHtml(name)}">
              <div class="ci-item-left">
                <div class="ci-item-header">
                  <span class="ci-item-name" title="Click to apply" data-action="apply">${escapeHtml(name)}</span>
                  <span class="ci-item-tag">${escapeHtml(instr.category || 'Other')}</span>
                </div>
                <div class="ci-item-meta">Created: ${created} • Last used: ${used}</div>
              </div>
              <div class="ci-item-actions">
                <button class="ci-icon-btn ci-pin-btn ${pinCls}" data-action="pin" title="${instr.isPinned ? 'Unpin' : 'Pin'}">📌</button>
                <button class="ci-icon-btn ci-edit-btn" data-action="edit" title="Edit">✏️</button>
                <button class="ci-icon-btn ci-copy-btn" data-action="copy" title="Copy content">📋</button>
                <button class="ci-icon-btn ci-share-btn" data-action="share" title="Share">🔗</button>
                <button class="ci-icon-btn ci-apply-btn" data-action="apply" title="Apply">✓</button>
                <button class="ci-icon-btn ci-delete-btn" data-action="delete" title="Delete">🗑</button>
              </div>
            </div>
          `;
        })
      .join('');

        list.innerHTML = html;
    };

      const resetEditor = () => {
          state.editMode = { active: false, originalName: null };
          const nameInput = dom.nameInput();
          const catSelect = dom.categorySelect();
          const saveBtn = dom.saveBtn();

          if (nameInput) nameInput.value = '';
          if (catSelect) catSelect.value = CONFIG.categories[1] || 'Other';
          setContentInputValue('');

          if (saveBtn) saveBtn.textContent = '💾 Save New Instruction';
      };

      const upsertInstructionFromEditor = () => {
          const nameInput = dom.nameInput();
          const catSelect = dom.categorySelect();
          const contentInput = dom.contentInput();
          if (!nameInput || !catSelect || !contentInput) return;

          const name = nameInput.value.trim();
          const category = catSelect.value;
          const content = contentInput.value.trim();

          if (!name || !content) {
              showMessage('Please provide both name and content', 'error');
              return;
          }

          // Rename handling
          const original = state.editMode.active ? state.editMode.originalName : null;
          const preservePinned = original && state.instructions[original] ? !!state.instructions[original].isPinned : false;
          const preserveCreatedAt = original && state.instructions[original] ? state.instructions[original].createdAt : Date.now();

          if (original && original !== name) {
              Storage.remove(original);
              delete state.instructions[original];
          }

          /** @type {Instruction} */
          const next = {
              content,
              category,
              isPinned: preservePinned,
              createdAt: preserveCreatedAt,
              lastUsed: Date.now(),
          };

          Storage.set(name, next);
          state.instructions[name] = next;

          showMessage(state.editMode.active ? 'Instruction updated' : 'Instruction saved', 'success');
          resetEditor();
          renderList();
      };

      const loadEditorFromInstruction = (name) => {
          const instr = state.instructions[name];
          if (!instr) return;

          state.editMode = { active: true, originalName: name };

          const nameInput = dom.nameInput();
          const catSelect = dom.categorySelect();
          const saveBtn = dom.saveBtn();

          if (nameInput) nameInput.value = name;
          if (catSelect) catSelect.value = instr.category || 'Other';
          setContentInputValue(instr.content || '');

          if (saveBtn) saveBtn.textContent = '💾 Update Instruction';

          nameInput?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      };

      const applyInstructionToChatGPT = (name) => {
          const instr = state.instructions[name];
          const textarea = dom.chatgptTextarea();
          if (!instr || !textarea) {
              showMessage('Could not find ChatGPT instructions textarea', 'error');
              return;
          }

          try {
              const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
              if (setter) setter.call(textarea, instr.content);
              else textarea.value = instr.content;

              textarea.dispatchEvent(new Event('input', { bubbles: true }));
              textarea.dispatchEvent(new Event('change', { bubbles: true }));

              // Update last-used
              const updated = { ...instr, lastUsed: Date.now() };
              Storage.set(name, updated);
              state.instructions[name] = updated;

              showMessage(`Applied: ${name}`, 'success');
              renderList();
          } catch (e) {
              // eslint-disable-next-line no-console
              console.error('Failed to apply instruction:', e);
              showMessage('Error applying instruction', 'error');
          }
      };

      const copyToClipboard = (text) => {
          try {
              GM_setClipboard(text);
              showMessage('Copied to clipboard', 'success');
          } catch (e) {
              // eslint-disable-next-line no-console
              console.error('Failed to copy:', e);
              showMessage('Error copying to clipboard', 'error');
          }
      };

      const exportInstructions = () => {
          try {
              const data = JSON.stringify(state.instructions, null, 2);
              const blob = new Blob([data], { type: 'application/json' });
              const url = URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = `chatgpt-instructions-${Date.now()}.json`;
              a.click();
              URL.revokeObjectURL(url);
              showMessage('Instructions exported successfully', 'success');
          } catch (e) {
              // eslint-disable-next-line no-console
              console.error('Export failed:', e);
              showMessage('Error exporting instructions', 'error');
          }
      };

      const importInstructions = (file) => {
          const reader = new FileReader();
          reader.onload = (e) => {
              try {
                  const imported = JSON.parse(String(e.target?.result ?? ''));
                  let count = 0;
                  for (const name of Object.keys(imported || {})) {
                      const instr = imported[name];
                      if (!instr || typeof instr.content !== 'string') continue;

                      /** @type {Instruction} */
                      const next = {
                          content: instr.content,
                          category: instr.category || 'Other',
                          isPinned: !!instr.isPinned,
                          createdAt: typeof instr.createdAt === 'number' ? instr.createdAt : Date.now(),
                          lastUsed: typeof instr.lastUsed === 'number' ? instr.lastUsed : Date.now(),
                      };

                      Storage.set(name, next);
                      state.instructions[name] = next;
                      count++;
                  }
                  showMessage(`Imported ${count} instructions successfully`, 'success');
                  renderList();
              } catch (err) {
                  // eslint-disable-next-line no-console
                  console.error('Import parse failed:', err);
                  showMessage('Invalid import file format', 'error');
              }
          };
          reader.readAsText(file);
      };

      const shareInstruction = (name) => {
          try {
              const instr = state.instructions[name];
              if (!instr) return;
              const data = btoa(unescape(encodeURIComponent(JSON.stringify({ name, ...instr }))));
              const url = `${window.location.origin}${window.location.pathname}#ci-share=${data}`;
              GM_setClipboard(url);
              showMessage('Share link copied to clipboard', 'success');
          } catch (e) {
              // eslint-disable-next-line no-console
              console.error('Share failed:', e);
              showMessage('Error creating share link', 'error');
          }
      };

      const checkSharedInstructionInHash = () => {
          const hash = window.location.hash;
          if (!hash.includes('ci-share=')) return;

          try {
              const data = hash.split('ci-share=')[1].split('&')[0];
              const decoded = JSON.parse(decodeURIComponent(escape(atob(data))));
              const name = decoded.name;
              delete decoded.name;

              if (name && typeof decoded.content === 'string' && window.confirm(`Import shared instruction "${name}"?`)) {
                  /** @type {Instruction} */
                  const next = {
                      content: decoded.content,
                      category: decoded.category || 'Other',
                      isPinned: false,
                      createdAt: Date.now(),
                      lastUsed: Date.now(),
                  };
                  Storage.set(name, next);
                  state.instructions[name] = next;
                  showMessage('Instruction imported successfully', 'success');

                  window.location.hash = hash.replace(/ci-share=[^&]*&?/, '');
                  renderList();
              }
          } catch (e) {
              // eslint-disable-next-line no-console
              console.error('Failed to import shared instruction:', e);
          }
      };

      const handleListClick = (evt) => {
          const target = /** @type {HTMLElement|null} */ (evt.target);
          const actionEl = target?.closest('[data-action]');
          const itemEl = target?.closest('.ci-item');
          const action = actionEl?.getAttribute('data-action');
          const name = itemEl?.getAttribute('data-name');

          if (!action || !name) return;

          const instr = state.instructions[name];
          if (!instr) return;

          switch (action) {
              case 'apply':
                  applyInstructionToChatGPT(name);
                  break;
              case 'edit':
                  loadEditorFromInstruction(name);
                  break;
              case 'copy':
                  copyToClipboard(instr.content || '');
                  break;
              case 'share':
                  shareInstruction(name);
                  break;
              case 'pin': {
                  const updated = { ...instr, isPinned: !instr.isPinned };
                  Storage.set(name, updated);
                  state.instructions[name] = updated;
                  renderList();
                  break;
              }
              case 'delete':
                  if (window.confirm(`Delete "${name}"?`)) {
                      Storage.remove(name);
                      delete state.instructions[name];
                      renderList();
                      // If currently editing this instruction, reset.
                      if (state.editMode.originalName === name) resetEditor();
                  }
                  break;
              default:
                  break;
          }
      };

      const togglePanel = () => {
          if (!state.panel || !state.toggleBtn) return;

          state.visible = !state.visible;
          state.panel.style.display = state.visible ? 'block' : 'none';

          if (state.visible) {
              state.dark = isDarkMode();
              state.panel.className = `ci-manager ${state.dark ? 'ci-manager-dark' : ''}`;

              loadIfNeeded();
              renderList();

              // Ensure char counter is correct on open
              raf(updateCharCounter);
              window.setTimeout(updateCharCounter, 0);

              checkSharedInstructionInHash();
          }
      };

      const mount = () => {
          if (!isPersonalizationPage()) return;
          if (qs(CONFIG.selectors.toggleBtn) || qs(CONFIG.selectors.panel)) return;

          injectStylesOnce();

          const section = qs(CONFIG.selectors.pageSection);
          if (!section) return;

          state.dark = isDarkMode();

          const toggleBtn = document.createElement('button');
          toggleBtn.className = 'ci-toggle-btn';
          toggleBtn.innerHTML = '📝 Manage Instructions';

          const panel = document.createElement('div');
          panel.className = `ci-manager ${state.dark ? 'ci-manager-dark' : ''}`;
          panel.style.display = 'none';

          panel.innerHTML = `
        <div class="ci-header">
          <h3 class="ci-title">Saved Instructions</h3>
          <button class="ci-close" type="button">×</button>
        </div>

        <div class="ci-toolbar">
          <button class="ci-toolbar-btn ci-export-btn" type="button" title="Export all">💾 Export</button>
          <button class="ci-toolbar-btn ci-import-btn" type="button" title="Import">📥 Import</button>
          <select class="ci-sort-select" title="Sort">
            <option value="lastUsed">Sort: Last Used</option>
            <option value="name">Sort: Name</option>
            <option value="dateCreated">Sort: Date Created</option>
          </select>
        </div>

        <div class="ci-search-box">
          <input type="text" class="ci-search-input" placeholder="Search instructions..." />
          <button class="ci-search-clear ci-hidden" type="button" title="Clear">×</button>
        </div>

        <div class="ci-filters">
          ${CONFIG.categories
          .map((cat) => `<button class="ci-filter-btn ${cat === 'All' ? 'active' : ''}" data-category="${escapeHtml(cat)}" type="button">${escapeHtml(cat)}</button>`)
          .join('')}
        </div>

        <input type="text" class="ci-input ci-name-input" placeholder="Instruction name" />

        <select class="ci-input ci-category-select">
          ${CONFIG.categories
          .slice(1)
          .map((cat) => `<option value="${escapeHtml(cat)}">${escapeHtml(cat)}</option>`)
          .join('')}
        </select>

        <textarea class="ci-input ci-textarea ci-content-input" placeholder="Enter custom instructions here..."></textarea>
        <div class="ci-char-counter">0 / ${CONFIG.charLimit} characters</div>

        <button class="ci-btn ci-btn-primary ci-save-btn" type="button">💾 Save New Instruction</button>
        <button class="ci-btn ci-btn-secondary ci-load-current-btn" type="button">📥 Load Current to Editor</button>

        <input type="file" class="ci-file-input" accept=".json" />

        <div class="ci-divider"></div>
        <div class="ci-list"></div>
      `;

        section.appendChild(toggleBtn);
        section.appendChild(panel);

        state.toggleBtn = toggleBtn;
        state.panel = panel;
        state.mounted = true;

        // Bind listeners
        state.abort = new AbortController();
        const { signal } = state.abort;

        toggleBtn.addEventListener('click', togglePanel, { signal });
        qs(CONFIG.selectors.closeBtn, panel)?.addEventListener('click', togglePanel, { signal });

        // Editor
        dom.saveBtn()?.addEventListener('click', upsertInstructionFromEditor, { signal });

        const contentInput = dom.contentInput();
        contentInput?.addEventListener('input', updateCharCounter, { signal });
        contentInput?.addEventListener('change', updateCharCounter, { signal });
        contentInput?.addEventListener('keyup', updateCharCounter, { signal });
        contentInput?.addEventListener('paste', () => raf(updateCharCounter), { signal });

        // Load current
        qs(CONFIG.selectors.loadCurrentBtn, panel)?.addEventListener(
            'click',
            () => {
                const t = dom.chatgptTextarea();
                const current = t?.value ?? '';
                if (!current) {
                    showMessage('No instructions found in textarea', 'error');
                    return;
                }
                setContentInputValue(current);
            },
            { signal }
        );

        // Export/Import
        qs(CONFIG.selectors.exportBtn, panel)?.addEventListener('click', exportInstructions, { signal });
        qs(CONFIG.selectors.importBtn, panel)?.addEventListener(
            'click',
            () => {
                dom.fileInput()?.click();
            },
            { signal }
        );

        dom.fileInput()?.addEventListener(
            'change',
            (e) => {
                const file = /** @type {HTMLInputElement} */ (e.currentTarget).files?.[0];
                if (file) importInstructions(file);
                // Reset so importing the same file again triggers change.
                /** @type {HTMLInputElement} */ (e.currentTarget).value = '';
            },
            { signal }
        );

        // Search
        const searchInput = dom.searchInput();
        const searchClear = dom.searchClear();

        searchInput?.addEventListener(
            'input',
            debounce((e) => {
                state.searchQuery = /** @type {HTMLInputElement} */ (e.target).value;
                searchClear?.classList.toggle('ci-hidden', !state.searchQuery);
                renderList();
            }, 250),
            { signal }
        );

        searchClear?.addEventListener(
            'click',
            () => {
                if (searchInput) searchInput.value = '';
                state.searchQuery = '';
                searchClear.classList.add('ci-hidden');
                renderList();
            },
            { signal }
        );

        // Sort
        dom.sortSelect()?.addEventListener(
            'change',
            (e) => {
                state.sortBy = /** @type {HTMLSelectElement} */ (e.target).value;
                renderList();
            },
            { signal }
        );

        // Filters (delegation)
        panel.querySelector('.ci-filters')?.addEventListener(
            'click',
            (e) => {
                const btn = /** @type {HTMLElement|null} */ (e.target)?.closest(CONFIG.selectors.filterBtn);
                if (!btn) return;

                const cat = btn.getAttribute('data-category') || 'All';
                state.selectedCategory = cat;

                qsa(CONFIG.selectors.filterBtn, panel).forEach((b) => b.classList.remove('active'));
                btn.classList.add('active');
                renderList();
            },
            { signal }
        );

        // List actions (delegation)
        dom.list()?.addEventListener('click', handleListClick, { signal });

        // Initial counter (covers browser restoring form value)
        raf(updateCharCounter);

        // Load shared instruction if present
        checkSharedInstructionInHash();
    };

      const unmount = () => {
          state.abort?.abort();
          state.abort = null;

          state.toggleBtn?.remove();
          state.panel?.remove();

          state.toggleBtn = null;
          state.panel = null;
          state.mounted = false;
          state.visible = false;
          state.loaded = false;
          state.instructions = {};
          state.searchQuery = '';
          state.selectedCategory = 'All';
          state.sortBy = 'lastUsed';
          state.editMode = { active: false, originalName: null };

          window.clearTimeout(state.messageTimeoutId);
          state.messageTimeoutId = 0;
      };

      return {
          mount,
          unmount,
          isMounted: () => state.mounted,
      };
  })();

    // ---- Utilities ----

    // Minimal escaping to keep list rendering safe.
    function escapeHtml(str) {
        return String(str)
            .replaceAll('&', '&amp;')
            .replaceAll('<', '&lt;')
            .replaceAll('>', '&gt;')
            .replaceAll('"', '&quot;')
            .replaceAll("'", '&#039;');
    }

    // ---- SPA navigation handling ----

    const init = () => {
        if (isPersonalizationPage()) {
            window.setTimeout(UI.mount, 500);
        }

        let lastUrl = window.location.href;
        const observer = new MutationObserver(
            debounce(() => {
                const url = window.location.href;

                const onTargetPage = isPersonalizationPage();
                if (url !== lastUrl) {
                    lastUrl = url;
                    UI.unmount();
                    if (onTargetPage) window.setTimeout(UI.mount, 500);
                    return;
                }

                // If still on page but UI got removed by re-render, mount again.
                if (onTargetPage && !UI.isMounted()) UI.mount();
                if (!onTargetPage && UI.isMounted()) UI.unmount();
            }, 250)
        );

        observer.observe(document.body, { childList: true, subtree: true });
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();