// ==UserScript== // @name Hashtag Bundler for X/Twitter and Bluesky // @namespace https://codymkw.nekoweb.org/ // @version 2.0 // @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; let panelVisible = false; const loadData = () => JSON.parse(GM_getValue(STORAGE_KEY, '{}')); const saveData = (d) => GM_setValue(STORAGE_KEY, JSON.stringify(d)); 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; ` }); // Scrollbar styling (dark, minimal) const style = document.createElement("style"); style.textContent = ` #bundle-container::-webkit-scrollbar { width: 6px; } #bundle-container::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } #bundle-container::-webkit-scrollbar-track { background: #222; } `; document.head.appendChild(style); 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', style: ` overflow-y: auto; max-height: 300px; padding-right: 3px; ` }); 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 — "#" 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); // Export / Import 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'); const importBtn = makeIOBtn('Import', '#2196F3'); exportBtn.onclick = () => { const blob = new Blob([JSON.stringify(loadData(), 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); }; 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); const merged = { ...loadData(), ...imported }; saveData(merged); render(); alert('Import OK!'); } catch { alert('Invalid JSON.'); } }; reader.readAsText(file); }; input.click(); }; ioControls.append(exportBtn, importBtn); container.appendChild(ioControls); // Bundles 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); 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 composer = document.querySelector('div[contenteditable="true"]'); if (!composer) return alert('Composer not found.'); const text = data[bundleName].join(' ') + ' '; composer.focus(); document.execCommand('insertText', false, text); insertBtn.textContent = 'Inserted!'; setTimeout(() => insertBtn.textContent = 'Insert', 800); }; copyBtn.onclick = async () => { await navigator.clipboard.writeText(data[bundleName].join(' ')).catch(() => alert('Copy error')); copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 800); }; editBtn.onclick = () => { const newTags = prompt('Edit hashtags separated by spaces — "#" 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}"?`)) return; delete data[bundleName]; saveData(data); render(); }; controls.append(insertBtn, copyBtn, editBtn, delBtn); inner.append(tagText, controls); entry.appendChild(inner); 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 p = document.getElementById(PANEL_ID); if (p) p.remove(); panelVisible = false; }; 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 }); })();