Greasy Fork is available in English.
Floating hashtag bundle manager with compact accordion UI, insertion at end, and export/import features
当前为
// ==UserScript==
// @name Hashtag Bundler for X/Twitter and Bluesky
// @namespace https://codymkw.nekoweb.org/
// @version 1.7
// @description Floating hashtag bundle manager with compact accordion UI, insertion at end, and export/import features
// @author Cody
// @match https://twitter.com/*
// @match https://x.com/*
// @match https://bsky.app/*
// @grant none
// @license MIT
// ==/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(localStorage.getItem(STORAGE_KEY) || '{}');
const saveData = (data) => localStorage.setItem(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:', 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 });
})();