// ==UserScript== // @name Hashtag Bundler for X/Twitter and Bluesky // @namespace https://codymkw.nekoweb.org/ // @version 1.9 // @description Simplify hashtags on X/Twitter & Bluesky with a floating organizer: group them in collapsible bundles, insert or paste into posts easily, and share via export/import. // @author Cody // @match https://twitter.com/* // @match https://x.com/* // @match https://bsky.app/* // @grant GM_getValue // @grant GM_setValue // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; let PANEL_ID, STORAGE_KEY; const hostname = window.location.hostname; if (hostname === 'twitter.com' || hostname === 'x.com') { PANEL_ID = 'twitter-hashtag-panel'; STORAGE_KEY = 'twitterHashtagBundles'; } else if (hostname === 'bsky.app') { PANEL_ID = 'bluesky-hashtag-panel'; STORAGE_KEY = 'blueskyHashtagBundles'; } else { return; // Exit if not on a supported site } let panelVisible = false; const loadData = () => JSON.parse(GM_getValue(STORAGE_KEY, '{}')); const saveData = (data) => GM_setValue(STORAGE_KEY, JSON.stringify(data)); const el = (tag, props = {}, children = []) => { const e = document.createElement(tag); Object.assign(e, props); children.forEach(c => e.appendChild(c)); return e; }; const createPanel = () => { if (document.getElementById(PANEL_ID)) return; const panel = el('div', { id: PANEL_ID, style: ` position: fixed; right: 20px; bottom: 20px; width: 280px; background: #1f1f1f; color: white; border-radius: 12px; padding: 10px; font-family: sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,0.4); z-index: 99999; ` }); const header = el('div', { style: 'font-weight:bold;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;' }); const title = el('span', { textContent: 'Hashtag Bundles' }); const toggle = el('span', { textContent: '▼', style: 'font-size:14px;' }); header.append(title, toggle); const container = el('div', { id: 'bundle-container' }); panel.appendChild(header); panel.appendChild(container); document.body.appendChild(panel); let collapsed = false; header.onclick = () => { collapsed = !collapsed; container.style.display = collapsed ? 'none' : 'block'; toggle.textContent = collapsed ? '▲' : '▼'; }; const data = loadData(); const render = () => { container.innerHTML = ''; const addBtn = el('button', { textContent: '➕ New Bundle', style: ` width:100%;padding:8px;margin-bottom:6px; border:none;border-radius:6px;background:#5865F2; color:white;font-weight:bold;cursor:pointer; ` }); addBtn.onclick = () => { const name = prompt('Bundle name?'); if (!name) return; const tags = prompt('Enter hashtags separated by spaces (e.g., #fun meme viral)—"#" is optional:', ''); if (!tags) return; const processed = tags .split(/\s+/) .filter(Boolean) .map(t => t.startsWith('#') ? t : `#${t}`); data[name] = processed; saveData(data); render(); }; container.appendChild(addBtn); // Add export/import buttons const ioControls = el('div', { style: 'display:flex;gap:6px;margin-bottom:10px;' }); const makeIOBtn = (label, bg) => el('button', { textContent: label, style: ` flex:1;padding:8px; border:none;border-radius:6px;background:${bg}; color:white;font-weight:bold;cursor:pointer; ` }); const exportBtn = makeIOBtn('Export', '#4CAF50'); exportBtn.onclick = () => { const currentData = loadData(); const blob = new Blob([JSON.stringify(currentData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${STORAGE_KEY}.json`; a.click(); URL.revokeObjectURL(url); }; const importBtn = makeIOBtn('Import', '#2196F3'); importBtn.onclick = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const imported = JSON.parse(ev.target.result); // Merge with current data (overrides duplicates) const current = loadData(); const merged = { ...current, ...imported }; saveData(merged); render(); alert('Imported successfully!'); } catch (err) { alert('Invalid JSON file.'); } }; reader.readAsText(file); }; input.click(); }; ioControls.append(exportBtn, importBtn); container.appendChild(ioControls); Object.keys(data).forEach(bundleName => { const entry = el('div', { style: ` background:#262626; border-radius:8px; margin-bottom:8px; padding:6px 8px; ` }); const row = el('div', { style: ` display:flex; justify-content:space-between; align-items:center; cursor:pointer; ` }); const nameEl = el('span', { textContent: bundleName, style: 'font-weight:bold;font-size:14px;' }); const arrow = el('span', { textContent: '▶', style: 'font-size:13px;transition:0.2s;' }); row.append(nameEl, arrow); entry.appendChild(row); // hidden section const inner = el('div', { style: ` display:none; margin-top:6px; padding-top:6px; border-top:1px solid #444; font-size:12px; color:#ccc; ` }); const tagText = el('div', { textContent: data[bundleName].join(' '), style: 'overflow-wrap:anywhere;margin-bottom:6px;' }); const controls = el('div', { style:'display:flex;gap:6px;' }); const makeBtn = (label, bg) => el('button', { textContent: label, style: ` padding:4px 6px; border:none; border-radius:4px; background:${bg}; color:white; cursor:pointer; font-size:11px; ` }); const insertBtn = makeBtn('Insert', '#2196F3'); const copyBtn = makeBtn('Copy', '#4CAF50'); const editBtn = makeBtn('Edit', '#555'); const delBtn = makeBtn('Delete', '#c0392b'); insertBtn.onclick = () => { const text = data[bundleName].join(' ') + ' '; const composer = document.querySelector('div[contenteditable="true"]'); if (composer) { composer.focus(); const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(composer); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); document.execCommand('insertText', false, text); insertBtn.textContent = 'Inserted!'; setTimeout(() => insertBtn.textContent = 'Insert', 1000); } else { alert('Composer not found.'); } }; copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(data[bundleName].join(' ')); copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 1000); } catch { alert('Clipboard error.'); } }; editBtn.onclick = () => { const newTags = prompt('Edit hashtags separated by spaces (e.g., #fun meme viral)—"#" is optional:', data[bundleName].join(' ')); if (!newTags) return; data[bundleName] = newTags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : `#${t}`); saveData(data); render(); }; delBtn.onclick = () => { if (confirm(`Delete "${bundleName}"?`)) { delete data[bundleName]; saveData(data); render(); } }; controls.append(insertBtn, copyBtn, editBtn, delBtn); inner.append(tagText, controls); entry.appendChild(inner); // accordion toggle row.onclick = () => { const show = inner.style.display === 'none'; inner.style.display = show ? 'block' : 'none'; arrow.style.transform = show ? 'rotate(90deg)' : 'rotate(0deg)'; }; container.appendChild(entry); }); }; render(); panelVisible = true; }; const removePanel = () => { const panel = document.getElementById(PANEL_ID); if (panel) panel.remove(); panelVisible = false; }; // Observe composer visibility const observer = new MutationObserver(() => { const composer = document.querySelector('div[contenteditable="true"]'); if (composer && !panelVisible) createPanel(); else if (!composer && panelVisible) removePanel(); }); observer.observe(document.body, { childList: true, subtree: true }); })();