OzBargain User Tags & Votes

Add customisable tags and track votes against users on OzBargain

目前為 2025-06-25 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name		OzBargain User Tags & Votes
// @namespace	nategasm
// @version		1.02
// @description	Add customisable tags and track votes against users on OzBargain
// @author		nategasm
// @license		MIT
// @include		https://www.ozbargain.com.au/*
// @icon		https://www.ozbargain.com.au/favicon.ico
// @run-at		document-end
// @grant		GM_setValue
// @grant		GM_getValue
// @grant		GM_addStyle
// ==/UserScript==

GM_addStyle(`
	.user-tag {
		margin-left: 3px;
	}
	.user-tag-name {
		padding: 1px 3px;
		margin-right: 3px;
		border-radius: 4px;
		font-weight: normal;
	}
	.user-tag .fa-tag {
		cursor: pointer;
	}
	.user-tag .fa-tag:hover {
		color: color-mix(in srgb, var(--shade2-bg) 90%, black) !important;
	}
	.user-tag .user-tag-votes {
		margin-right: 3px;
	}
	#tooltip.user-tag-modal {
		display: block;
		position: absolute;
		z-index: 10000;
		transition: opacity 0.3s ease;
		opacity: 1;
	}
	#tooltip.user-tag-modal.fade-out {
		opacity: 0;
	}
	.submitted strong:has(.user-tag),
	.submitted strong:has(.user-tag) a,
	.submitted strong:has(.user-tag-name) a,
	.submitted strong:has(.user-tag) .fa-tag {
		margin-right: 0px !important
	}
	.node .submitted strong:not(:has(.user-tag-name)) a {
		margin-right: 3px !important
	}
	.user-tag-modal input[type="color"] {
		width: 40px;
		height: 20px;
		padding: 2px;
		cursor: pointer;
	}
	#tooltip.user-tag-modal.left-offset::before {
		left: var(--arrow-offset, 50%);
	}
	#tooltip.user-tag-modal.above-icon::before {
		border-bottom: none;
		border-top-color: var(--tooltip-clr);
		top: 100%;
	}
`);

const DEFAULT_BG_COLOR = '#FFA500';
const DEFAULT_TEXT_COLOR = '#000000';
const TAG_STORAGE_KEY = 'userTagsById';
const VOTE_TRACKING_KEY = 'userTagsVoteTracking';
const voteCooldown = new Set();

async function getTags() {
	return (await GM_getValue(TAG_STORAGE_KEY)) || {};
}

async function saveTags(tags) {
	await GM_setValue(TAG_STORAGE_KEY, tags);
}

async function isVoteTrackingEnabled() {
	return (await GM_getValue(VOTE_TRACKING_KEY)) !== false; //Default to true
}

async function setVoteTrackingEnabled(enabled) {
	await GM_setValue(VOTE_TRACKING_KEY, enabled);
}

async function setTag(userId, tag, bgColor, textColor) {
	const tags = await getTags();
	tags[userId] = {
		...(tags[userId] || {}),
		tag: tag || undefined,
		bg: bgColor || tags[userId]?.bg || undefined,
		txt: textColor || tags[userId]?.txt || undefined
	};
	if (!tags[userId].tag && !tags[userId].bg && !tags[userId].txt &&
		(!tags[userId].pV || tags[userId].pV === 0) && (!tags[userId].nV || tags[userId].nV === 0)) {
		delete tags[userId];
	}
	await saveTags(tags);
}

async function clearAllTags() {
	if (confirm('Are you sure you want to delete tags for all users? \nThis cannot be reversed unless you have exported a backup')) {
	  await saveTags({});
	  alert('All tags deleted. Refresh to see changes');
	}
}

function extractUserId(anchor) {
	const match = anchor.getAttribute('href').match(/\/user\/(\d+)/);
	return match ? match[1] : null;
}

function closeExistingModal() {
	const existing = document.querySelector('.user-tag-modal');
	if (existing) existing.remove();
}

async function createTagElement(userId, tagData = {}, username = '') {
	const wrapper = document.createElement('span');
	wrapper.classList.add('user-tag');
	//Add tag
	if (tagData.tag) {
		const span = document.createElement('span');
		span.classList.add('user-tag-name');
		span.textContent = tagData.tag;
		span.style.backgroundColor = tagData.bg || DEFAULT_BG_COLOR;
		span.style.color = tagData.txt || DEFAULT_TEXT_COLOR;
		wrapper.appendChild(span);
	}
	//Add votes
	const showVotes = await isVoteTrackingEnabled();
	if (showVotes && (tagData.pV || tagData.nV)) {
		const voteCount = (tagData.pV || 0) - (tagData.nV || 0);
		const voteEl = document.createElement('span');
		voteEl.classList.add('user-tag-votes');
		voteEl.textContent = `[${voteCount >= 0 ? '+' : ''}${voteCount}]`;
		voteEl.title = `${tagData.pV || 0} upvotes, ${tagData.nV || 0} downvotes`;
		wrapper.appendChild(voteEl);
	}

	const icon = document.createElement('span');
	icon.classList.add('fa','fa-tag');
	icon.title = 'Edit tag';
	icon.addEventListener('click', async (e) => {
		e.stopPropagation();
		closeExistingModal();

		const modal = document.createElement('div');
		modal.setAttribute('id', 'tooltip');
		modal.classList.add('user-tag-modal');

		const head = document.createElement('div');
		head.setAttribute('id', 'tooltip-head');
		const headClose = document.createElement('i');
		headClose.setAttribute('id', 'tooltip-close');
		headClose.classList.add('fa','fa-times');
		headClose.onclick = () => fadeOutAndRemove(modal);
		const headTitle = document.createElement('div');
		headTitle.setAttribute('id', 'tooltip-title');
		headTitle.textContent = 'User Tag Editor';
		head.appendChild(headClose);
		head.appendChild(headTitle);

		const toggleVotesCheckbox = document.createElement('input');
		toggleVotesCheckbox.type = 'checkbox';
		toggleVotesCheckbox.id = 'toggleVotes';
		toggleVotesCheckbox.style.marginBottom = '6px';
		toggleVotesCheckbox.checked = await isVoteTrackingEnabled();
		toggleVotesCheckbox.style.cursor = 'pointer';
		toggleVotesCheckbox.addEventListener('change', async () => {
			await setVoteTrackingEnabled(toggleVotesCheckbox.checked);
			alert('Vote tracking setting saved. Refresh page to apply');
		});

		const toggleLabel = document.createElement('label');
		toggleLabel.htmlFor = 'toggleVotes';
		toggleLabel.textContent = 'Enable vote tracking';

		const voteInfo = document.createElement('div');
		const net = (tagData.pV || 0) - (tagData.nV || 0);
		voteInfo.innerHTML = `<b>Votes:</b> +${tagData.pV || 0} / -${tagData.nV || 0} [<b>${net > 0 ? '+' : ''}${net}</b>]`;

		const resetVotesBtn = document.createElement('button');
		resetVotesBtn.textContent = 'Reset Votes';
		resetVotesBtn.classList.add('btn');
		resetVotesBtn.style.fontSize = '13px';
		resetVotesBtn.style.padding = '1px 4px';
		resetVotesBtn.style.marginLeft = '10px';
		resetVotesBtn.onclick = async () => {
			if (!tagData.pV && !tagData.nV) return;
			if (confirm(`Are you sure you want to reset votes for ${username}?`)) {
				tagData.pV = 0;
				tagData.nV = 0;
				const tags = await getTags();
				tags[userId] = tagData;
				if (!tags[userId].tag && !tags[userId].bg && !tags[userId].txt &&
					(!tags[userId].pV || tags[userId].pV === 0) && (!tags[userId].nV || tags[userId].nV === 0)) {
					delete tags[userId];
				}
				await saveTags(tags);
				refreshUser(userId);
				fadeOutAndRemove(modal);
			}
		};
		voteInfo.appendChild(resetVotesBtn);

		const label = document.createElement('label');
		label.innerHTML = `Tag for <b>${username}</b>`;
		const input = document.createElement('input');
		input.type = 'text';
		input.value = tagData.tag || '';
		input.style.marginTop = '5px';
		input.style.marginBottom = '10px';

		const bgColorInput = document.createElement('input');
		bgColorInput.type = 'color';
		bgColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.bg) ? tagData.bg : DEFAULT_BG_COLOR;
		bgColorInput.style.marginTop = '5px';
		bgColorInput.style.marginBottom = '10px';

		const textColorInput = document.createElement('input');
		textColorInput.type = 'color';
		textColorInput.value = /^#[0-9A-Fa-f]{6}$/.test(tagData.txt) ? tagData.txt : DEFAULT_TEXT_COLOR;
		textColorInput.style.marginBottom = '10px';

		const preview = document.createElement('span');
		preview.textContent = input.value;
		preview.style.padding = '2px 4px';
		preview.style.borderRadius = '4px';
		preview.style.backgroundColor = bgColorInput.value;
		preview.style.color = textColorInput.value;
		preview.style.fontSize = 'smaller';

		function updatePreview() {
			preview.textContent = input.value;
			preview.style.backgroundColor = bgColorInput.value;
			preview.style.color = textColorInput.value;
		}

		input.addEventListener('input', updatePreview);
		bgColorInput.addEventListener('input', updatePreview);
		textColorInput.addEventListener('input', updatePreview);

		const resetBtn = document.createElement('button');
		resetBtn.textContent = 'Reset Colour';
		resetBtn.classList.add('btn');
		resetBtn.style.marginRight = '5px';
		resetBtn.onclick = () => {
			bgColorInput.value = DEFAULT_BG_COLOR;
			textColorInput.value = DEFAULT_TEXT_COLOR;
			updatePreview();
		};

		const saveBtn = document.createElement('button');
		saveBtn.textContent = 'Save Tag';
		saveBtn.classList.add('btn','btn-primary');
		saveBtn.onclick = async () => {
			await setTag(userId, input.value, bgColorInput.value, textColorInput.value);
			refreshUser(userId);
			fadeOutAndRemove(modal);
		};

		input.addEventListener('keydown', async (e) => {
			if (e.key === 'Enter') {
				e.preventDefault();
				await saveBtn.onclick();
			}
		});

		const exportBtn = document.createElement('button');
		exportBtn.textContent = 'Export Data';
		exportBtn.classList.add('btn');
		exportBtn.style.fontSize = '13px';
		exportBtn.style.padding = '1px 4px';
		exportBtn.style.marginRight = '5px';
		exportBtn.onclick = async () => {
			const tags = await getTags();
			const blob = new Blob([JSON.stringify(tags, null, 2)], { type: 'application/json' });
			const url = URL.createObjectURL(blob);
			const a = document.createElement('a');
			const currentDate = new Date();
			let month;
			if (currentDate.getMonth() < 9) {
				month = '0' + (currentDate.getMonth() + 1);
			} else {
				month = currentDate.getMonth() + 1;
			}
			let day;
			if (currentDate.getDate() < 10) {
				day = '0' + currentDate.getDate();
			} else {
				day = currentDate.getDate();
			}
			a.href = url;
			a.download = 'OzB_Usertags_Backup_' + currentDate.getFullYear() + month + day + '.json';
			a.click();
		};

		const importBtn = document.createElement('button');
		importBtn.textContent = 'Import Data';
		importBtn.classList.add('btn');
		importBtn.style.fontSize = '13px';
		importBtn.style.padding = '1px 4px';
		importBtn.style.marginRight = '5px';
		importBtn.onclick = () => {
			if (confirm(`Are you sure you want to import data? \nThis will overwrite existing user tags if they are also in the import but votes will be merged/added together \nNew users from Import will be added. Existing users not in import will be kept`)) {
				const input = document.createElement('input');
				input.type = 'file';
				input.accept = 'application/json';
				input.onchange = async (e) => {
					const file = e.target.files[0];
					if (file) {
						const text = await file.text();
						try {
							const importedTags = JSON.parse(text);
							const existingTags = await getTags();
							//Overwrite tag,bg,txt. Add pV,nV
							for (const userId in importedTags) {
								const imported = importedTags[userId];
								const existing = existingTags[userId] || {};
								existingTags[userId] = {
									tag: imported.tag !== undefined ? imported.tag : existing.tag,
									bg: imported.bg !== undefined ? imported.bg : existing.bg,
									txt: imported.txt !== undefined ? imported.txt : existing.txt,
									pV: (existing.pV || 0) + (imported.pV || 0),
									nV: (existing.nV || 0) + (imported.nV || 0),
								};
							}
							await saveTags(existingTags);
							alert('Tags imported and merged! \nRefresh the page to see changes');
							addTags(); //Refresh tags on the page
						} catch (err) {
							alert('Error importing: Invalid JSON');
						}
					}
				};
				input.click();
			}
		};
		const deleteAllBtn = document.createElement('button');
		deleteAllBtn.textContent = 'Delete All';
		deleteAllBtn.classList.add('btn');
		deleteAllBtn.style.fontSize = '13px';
		deleteAllBtn.style.padding = '1px 4px';
		deleteAllBtn.onclick = clearAllTags;

		modal.appendChild(head);
		const tooltip = document.createElement('div');
		tooltip.classList.add('tooltip','tooltipuser');
		tooltip.appendChild(toggleLabel);
		tooltip.appendChild(toggleVotesCheckbox);
		tooltip.appendChild(voteInfo);
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(label);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(input);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(preview);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(document.createTextNode('Background: '));
		tooltip.appendChild(bgColorInput);
		tooltip.appendChild(document.createTextNode('\u00A0\u00A0Text: '));
		tooltip.appendChild(textColorInput);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(resetBtn);
		tooltip.appendChild(saveBtn);
		tooltip.appendChild(document.createElement('br'));
		tooltip.appendChild(document.createElement('hr'));
		tooltip.appendChild(exportBtn);
		tooltip.appendChild(importBtn);
		tooltip.appendChild(deleteAllBtn);
		modal.appendChild(tooltip);
		document.body.appendChild(modal);
		positionModalUnderIcon(modal, e.currentTarget);
	});
	wrapper.appendChild(icon);
	return wrapper;
}

function positionModalUnderIcon(modal, iconElement, modalWidth = 253, modalHeight = 310, verticalOffset = 10) {
	const iconRect = iconElement.getBoundingClientRect();
	let top = iconRect.bottom + window.scrollY + verticalOffset;
	let left = iconRect.left + window.scrollX + (iconRect.width / 2) - (modalWidth / 2);
	const viewportWidth = document.documentElement.clientWidth;
	const viewportHeight = document.documentElement.clientHeight;
	// Adjust right overflow
	if (left + modalWidth > window.scrollX + viewportWidth) {
		left = window.scrollX + viewportWidth - modalWidth - 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust left overflow
	if (left < window.scrollX) {
		left = window.scrollX + 10;
		modal.classList.add('left-offset');
		const arrowOffset = iconRect.left + iconRect.width / 2 - left;
		modal.style.setProperty('--arrow-offset', `${arrowOffset}px`);
	}
	// Adjust bottom overflow (show above icon)
	if (top + modalHeight > window.scrollY + viewportHeight) {
		top = iconRect.top + window.scrollY - modalHeight - verticalOffset;
		modal.classList.add('above-icon');
	}
	modal.style.top = `${top}px`;
	modal.style.left = `${left}px`;
}

function fadeOutAndRemove(el) {
	if (!el) return;
	el.classList.add('fade-out');
	setTimeout(() => el.remove(), 300); //300ms matches the CSS transition
}

async function handleVoteClick(voteBtn, delta) {
	if (!await isVoteTrackingEnabled()) return;
	const container = voteBtn.closest('.c-vote, .n-vote');
	if (!container) return;
	const meta = voteBtn.closest('.meta, .node');
	if (!meta) return;
	const anchor = meta.querySelector('.submitted strong a[href^="/user/"]');
	const userId = extractUserId(anchor);
	if (!userId || voteCooldown.has(userId)) return;
	//Wait and see if negative deal votes get accepted due to comment requirement
	if (meta.className.includes('node') && delta < 0) {
		setTimeout(() => {
			const newContainer = voteBtn.closest('.n-vote');
			if (newContainer.classList.contains('inact')) return;
			processVoteResult(userId, container, delta);
		}, 200);
	} else {
		processVoteResult(userId, container, delta);
	}
}

async function processVoteResult(userId, container, delta) {
	const tags = await getTags();
	tags[userId] = tags[userId] || {};
	// Determine vote state
	const hasPending = container.classList.contains('pending');
	const isVoteUp = container.classList.contains('voteup');
	const isVoteDown = container.classList.contains('votedown');
	// Vote change logic
	if (hasPending) {
		// User is changing their vote
		if (delta > 0 && isVoteDown) {
			// Changing from negative to positive
			tags[userId].nV = Math.max((tags[userId].nV || 1) - 1, 0);
			tags[userId].pV = (tags[userId].pV || 0) + 1;
		} else if (delta < 0 && isVoteUp) {
			// Changing from positive to negative
			tags[userId].pV = Math.max((tags[userId].pV || 1) - 1, 0);
			tags[userId].nV = (tags[userId].nV || 0) + 1;
		} else {
			// Same vote clicked again, no changes
			return;
		}
	} else if (container.classList.contains('inact')) {
		// First time voting
		if (delta > 0) tags[userId].pV = (tags[userId].pV || 0) + 1;
		else tags[userId].nV = (tags[userId].nV || 0) + 1;
	} else {
		// Already voted and no change allowed (or multiple clicks)
		return;
	}
	await saveTags(tags);
	refreshUser(userId, tags);
	voteCooldown.add(userId);
	setTimeout(() => voteCooldown.delete(userId), 1000); //debounce
}

async function addTags() {
	const tags = await getTags();
	if (location.href.indexOf('/user/') > 0) {
		const userId = location.pathname.split("/")[2];
		if (!userId) return;
		if (!document.querySelector('.user-tag')) {
			const title = document.querySelector('h1#title');
			const username = title?.getAttribute('data-title')?.match(/^(.+?)\s+\(#\d+\)/)?.[1];
			const tagEl = await createTagElement(userId, tags[userId], username);
			tagEl.style.position = 'relative';
			tagEl.style.top = '-3px';
			title.style.display = 'inline-block';
			title.style.marginRight = '5px';
			title.insertAdjacentElement('afterend', tagEl);
		}
	} else {
		const anchors = document.querySelectorAll('.submitted strong a[href^="/user/"]');
		for (const a of anchors) {
			const userId = extractUserId(a);
			if (!userId) continue;
			if (!a.parentElement.querySelector('.user-tag')) {
				const username = a.textContent.trim();
				const tagEl = await createTagElement(userId, tags[userId], username);
				a.parentElement.appendChild(tagEl);
			}
		}
	}
	//Add click listeners to all clickable votes
	document.querySelectorAll('.c-vote.inact .cvb.voteup, .n-vote.inact .nvb.voteup').forEach(btn => {
		if (!btn.dataset.listenerAdded) {
			btn.dataset.listenerAdded = 'true';
			btn.addEventListener('click', () => handleVoteClick(btn, 1));
		}
	});
	document.querySelectorAll('.c-vote.inact .cvb.votedown, .n-vote.inact .nvb.votedown').forEach(btn => {
		if (!btn.dataset.listenerAdded) {
			btn.dataset.listenerAdded = 'true';
			btn.addEventListener('click', () => handleVoteClick(btn, -1));
		}
	});
}

async function refreshUser(userId, tags) {
	if (!tags) {
		tags = await getTags();
	}
	if (location.href.indexOf('/user/') > 0) {
		const title = document.querySelector('h1#title');
		const existingTag = title.parentElement.querySelector('.user-tag');
		if (existingTag) existingTag.remove();
		const username = title?.getAttribute('data-title')?.match(/^(.+?)\s+\(#\d+\)/)?.[1];
		const tagEl = await createTagElement(userId, tags[userId], username);
		tagEl.style.position = 'relative';
		tagEl.style.top = '-3px';
		title.style.display = 'inline-block';
		title.style.marginRight = '5px';
		title.insertAdjacentElement('afterend', tagEl);
	} else {
		const anchors = document.querySelectorAll(`.submitted strong a[href="/user/${userId}"]`);
		for (const a of anchors) {
			const existingTag = a.parentElement.querySelector('.user-tag');
			if (existingTag) existingTag.remove();
			const username = a.textContent.trim();
			const tagEl = await createTagElement(userId, tags[userId], username);
			a.parentElement.appendChild(tagEl);
		}
	}
}

function observePages() { //Observe pages that can dynamically load nodes/comments
	const infScroll = document.querySelector(".infscrollbtn"); //Pages with infinite scroll
	const hiddenNode = document.querySelector(".comment.hidden"); //Pages with hidden commments
	if (infScroll || hiddenNode) {
		const callback = (mutationList, observer) => {
			for (const mutation of mutationList) {
				if (mutation.addedNodes.length > 0 && mutation.addedNodes[0].className && (mutation.addedNodes[0].className.includes("comment") || mutation.addedNodes[0].className.includes("node")) ) {
					addTags();
				}
			}
		};
		const observer = new MutationObserver(callback);
		observer.observe(document.body, { childList: true, subtree: true });
	}
}

addTags();
observePages();